Add tool annotations and PR close/reopen support (#174)

Add MCP `ToolAnnotation` metadata (Title, ReadOnlyHint, DestructiveHint)
to all registered tools so MCP hosts (VS Code, Claude, Cursor) get
accurate per-tool hints. A shared `pkg/annotation` package exposes
`ReadOnly`, `Write`, and `Destructive` helpers for consistency.

Add `close` and `reopen` methods to `pull_request_write` so PR state
can be toggled without going through the generic `update` path.

Co-Authored-By: silverwind <me@silverwind.io>
Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
unpossible
2026-05-10 11:25:22 +02:00
committed by silverwind
parent 4c45b42cb5
commit 329a97d5d2
21 changed files with 296 additions and 2 deletions
+71 -2
View File
@@ -6,6 +6,7 @@ import (
"net/url"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
@@ -30,6 +31,7 @@ var (
ListRepoPullRequestsTool = mcp.NewTool(
ListRepoPullRequestsToolName,
mcp.WithDescription("List repository pull requests"),
mcp.WithToolAnnotation(annotation.ReadOnly("List pull requests")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("state"), mcp.Enum("open", "closed", "all"), mcp.DefaultString("all")),
@@ -42,6 +44,7 @@ var (
PullRequestReadTool = mcp.NewTool(
PullRequestReadToolName,
mcp.WithDescription("Get pull request information. Use method 'get' for PR details, 'get_diff' for diff, 'get_reviews'/'get_review'/'get_review_comments' for review data."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read pull request details")),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_diff", "get_reviews", "get_review", "get_review_comments")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
@@ -54,8 +57,9 @@ var (
PullRequestWriteTool = mcp.NewTool(
PullRequestWriteToolName,
mcp.WithDescription("Create, update, or merge pull requests, manage reviewers."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "merge", "add_reviewers", "remove_reviewers")),
mcp.WithDescription("Create, update, close, reopen, or merge pull requests, manage reviewers."),
mcp.WithToolAnnotation(annotation.Write("Create, update, close, reopen, or merge pull requests")),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "close", "reopen", "merge", "add_reviewers", "remove_reviewers")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Description("pull request index (required for all methods except 'create')")),
@@ -85,6 +89,7 @@ var (
PullRequestReviewWriteTool = mcp.NewTool(
PullRequestReviewWriteToolName,
mcp.WithDescription("Manage pull request reviews: create, submit, delete, or dismiss."),
mcp.WithToolAnnotation(annotation.Write("Submit a pull request review")),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "submit", "delete", "dismiss")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
@@ -156,6 +161,10 @@ func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return createPullRequestFn(ctx, req)
case "update":
return editPullRequestFn(ctx, req)
case "close":
return closePullRequestFn(ctx, req)
case "reopen":
return reopenPullRequestFn(ctx, req)
case "merge":
return mergePullRequestFn(ctx, req)
case "add_reviewers":
@@ -167,6 +176,66 @@ func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
}
}
func closePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
state := gitea_sdk.StateClosed
pr, _, err := client.EditPullRequest(owner, repo, index, gitea_sdk.EditPullRequestOption{
State: &state,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("close %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimPullRequest(pr))
}
func reopenPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
state := gitea_sdk.StateOpen
pr, _, err := client.EditPullRequest(owner, repo, index, gitea_sdk.EditPullRequestOption{
State: &state,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("reopen %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimPullRequest(pr))
}
func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
+128
View File
@@ -911,3 +911,131 @@ func Test_getPullRequestByIndexFn_assetsFailureNonFatal(t *testing.T) {
t.Fatalf("expected PR body preserved when assets fail, got: %s", body)
}
}
func Test_closePullRequestFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
)
var gotBody map[string]any
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
if r.Method != http.MethodPatch {
t.Errorf("expected PATCH method, got %s", r.Method)
}
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(fmt.Appendf(nil, `{"index":%d,"title":"Fix bug","state":"closed","head":{"ref":"fix-branch"},"base":{"ref":"main"}}`, index))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
})
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
origHost := flag.Host
origToken := flag.Token
flag.Host = server.URL
flag.Token = "test-token"
t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken })
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"method": "close",
"owner": owner,
"repo": repo,
"index": float64(index),
},
},
}
result, err := closePullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("closePullRequestFn() error = %v", err)
}
if gotBody["state"] != "closed" {
t.Errorf("expected state=closed, got %v", gotBody["state"])
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
}
func Test_reopenPullRequestFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
)
var gotBody map[string]any
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
if r.Method != http.MethodPatch {
t.Errorf("expected PATCH method, got %s", r.Method)
}
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(fmt.Appendf(nil, `{"index":%d,"title":"Fix bug","state":"open","head":{"ref":"fix-branch"},"base":{"ref":"main"}}`, index))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
})
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
origHost := flag.Host
origToken := flag.Token
flag.Host = server.URL
flag.Token = "test-token"
t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken })
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"method": "reopen",
"owner": owner,
"repo": repo,
"index": float64(index),
},
},
}
result, err := reopenPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("reopenPullRequestFn() error = %v", err)
}
if gotBody["state"] != "open" {
t.Errorf("expected state=open, got %v", gotBody["state"])
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
}