refactor!: replace mcp-go with the official MCP Go SDK

- Swap the mark3labs MCP dependency for modelcontextprotocol/go-sdk v1.7.0
- Add a declarative tool definition and JSON Schema builder to the tool package
- Adapt registered tools to the official low-level handler, recovering panics and mapping failures to JSON-RPC errors
- Narrow tool handlers to take a plain argument map instead of an SDK request type
- Rewire stdio and HTTP transports onto the official server, moving Authorization parsing into receiving middleware
- Add a golden contract test that locks the exposed tool schemas, plus SDK integration and helper tests
- Add a test target and run it in the pull request workflow

BREAKING CHANGE: The exported helpers change signature. Tool.RegisterRead and
Tool.RegisterWrite now take tool.ServerTool instead of server.ServerTool, and the
annotation constructors return *mcp.ToolAnnotations from the official SDK. Callers
must build tool definitions with tool.NewDefinition and handlers with the
func(context.Context, map[string]any) signature.

The 30-second SSE heartbeat is removed because the official SDK has no equivalent.
ServerOptions.KeepAlive is deliberately not used as a substitute, since it sends MCP
ping requests and ping is removed in protocol 2026. HTTP stays stateless=false, so
new clients negotiate at most 2025-11-25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bo-Yi Wu
2026-08-02 21:30:19 +08:00
parent 290d06b40b
commit 80c8b25d6e
49 changed files with 5277 additions and 1528 deletions
+41 -39
View File
@@ -3,7 +3,6 @@ package issue
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
@@ -17,51 +16,51 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const AttachmentReadToolName = "attachment_read"
var AttachmentReadTool = mcp.NewTool(
var AttachmentReadTool = tool.NewDefinition(
AttachmentReadToolName,
mcp.WithDescription("Read issue/comment attachments: list metadata, get metadata, or download content."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read issue or comment attachments")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get", "download")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("issue_number", mcp.Description("required for issue attachment list/get or issue-scoped metadata lookup")),
mcp.WithNumber("comment_id", mcp.Description("required for comment attachment list/get or comment-scoped metadata lookup")),
mcp.WithNumber("attachment_id", mcp.Description("required for get and for download when attachment_uuid is not provided")),
mcp.WithString("attachment_uuid", mcp.Description("attachment UUID for direct download path lookup")),
mcp.WithString("output_path", mcp.Description("write the attachment to this exact path")),
"Read issue/comment attachments: list metadata, get metadata, or download content.",
annotation.ReadOnly("Read issue or comment attachments"),
tool.String("method", tool.Required(), tool.Enum("list", "get", "download")),
tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
tool.Number("issue_number", tool.Description("required for issue attachment list/get or issue-scoped metadata lookup")),
tool.Number("comment_id", tool.Description("required for comment attachment list/get or comment-scoped metadata lookup")),
tool.Number("attachment_id", tool.Description("required for get and for download when attachment_uuid is not provided")),
tool.String("attachment_uuid", tool.Description("attachment UUID for direct download path lookup")),
tool.String("output_path", tool.Description("write the attachment to this exact path")),
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: AttachmentReadTool, Handler: attachmentReadFn})
Tool.RegisterRead(tool.ServerTool{Tool: AttachmentReadTool, Handler: attachmentReadFn})
}
func attachmentReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
func attachmentReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listAttachmentsFn(ctx, req)
return listAttachmentsFn(ctx, args)
case "get":
return getAttachmentFn(ctx, req)
return getAttachmentFn(ctx, args)
case "download":
return downloadAttachmentFn(ctx, req)
return downloadAttachmentFn(ctx, args)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listAttachmentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, repo, issueNumber, commentID, err := attachmentScopeArgs(req)
func listAttachmentsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, repo, issueNumber, commentID, err := attachmentScopeArgs(args)
if err != nil {
return to.ErrorResult(err)
}
@@ -81,29 +80,29 @@ func listAttachmentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.TextResult(slimAttachments(attachments))
}
func getAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
att, err := lookupAttachment(ctx, req)
func getAttachmentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
att, err := lookupAttachment(ctx, args)
if err != nil {
return to.ErrorResult(err)
}
return to.TextResult(slimAttachment(att))
}
func downloadAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func downloadAttachmentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
explicitOutputPath := params.GetOptionalString(req.GetArguments(), "output_path", "")
attachmentUUID := strings.TrimSpace(params.GetOptionalString(req.GetArguments(), "attachment_uuid", ""))
explicitOutputPath := params.GetOptionalString(args, "output_path", "")
attachmentUUID := strings.TrimSpace(params.GetOptionalString(args, "attachment_uuid", ""))
var att *gitea_sdk.Attachment
if attachmentUUID == "" {
att, err = lookupAttachment(ctx, req)
att, err = lookupAttachment(ctx, args)
if err != nil {
return to.ErrorResult(err)
}
@@ -132,7 +131,10 @@ func downloadAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
}
if len(limited) <= flag.MaxInlineAttachmentBytes {
text := fmt.Sprintf("attachment %s (%s, %d bytes, %s)", name, attachmentUUID, len(limited), mimeType)
return mcp.NewToolResultImage(text, base64.StdEncoding.EncodeToString(limited), mimeType), nil
return &mcp.CallToolResult{Content: []mcp.Content{
&mcp.TextContent{Text: text},
&mcp.ImageContent{Data: limited, MIMEType: mimeType},
}}, nil
}
outputPath := defaultAttachmentPath(owner, repo, name, attachmentUUID)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
@@ -185,29 +187,29 @@ func attachmentFileResult(att *gitea_sdk.Attachment, outputPath string, written
return to.TextResult(res)
}
func attachmentScopeArgs(req mcp.CallToolRequest) (owner, repo string, issueNumber, commentID int64, err error) {
owner, err = params.GetString(req.GetArguments(), "owner")
func attachmentScopeArgs(args map[string]any) (owner, repo string, issueNumber, commentID int64, err error) {
owner, err = params.GetString(args, "owner")
if err != nil {
return "", "", 0, 0, err
}
repo, err = params.GetString(req.GetArguments(), "repo")
repo, err = params.GetString(args, "repo")
if err != nil {
return "", "", 0, 0, err
}
issueNumber = params.GetOptionalInt(req.GetArguments(), "issue_number", 0)
commentID = params.GetOptionalInt(req.GetArguments(), "comment_id", 0)
issueNumber = params.GetOptionalInt(args, "issue_number", 0)
commentID = params.GetOptionalInt(args, "comment_id", 0)
if (issueNumber > 0) == (commentID > 0) {
return "", "", 0, 0, errors.New("exactly one of issue_number or comment_id is required")
}
return owner, repo, issueNumber, commentID, nil
}
func lookupAttachment(ctx context.Context, req mcp.CallToolRequest) (*gitea_sdk.Attachment, error) {
owner, repo, issueNumber, commentID, err := attachmentScopeArgs(req)
func lookupAttachment(ctx context.Context, args map[string]any) (*gitea_sdk.Attachment, error) {
owner, repo, issueNumber, commentID, err := attachmentScopeArgs(args)
if err != nil {
return nil, err
}
attachmentID := params.GetOptionalInt(req.GetArguments(), "attachment_id", 0)
attachmentID := params.GetOptionalInt(args, "attachment_id", 0)
if attachmentID <= 0 {
return nil, errors.New("attachment_id is required")
}
+66 -7
View File
@@ -1,7 +1,9 @@
package issue
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -14,7 +16,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestAttachmentFilename(t *testing.T) {
@@ -79,13 +81,13 @@ func TestAttachmentReadListIssueAttachments(t *testing.T) {
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
res, err := attachmentReadFn(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
res, err := attachmentReadFn(context.Background(), map[string]any{
"method": "list", "owner": owner, "repo": repo, "issue_number": float64(42),
}}})
})
if err != nil {
t.Fatalf("attachmentReadFn() error = %v", err)
}
body := res.Content[0].(mcp.TextContent).Text
body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `"mime_type":"image/png"`) || !strings.Contains(body, `"uuid":"uuid-1"`) {
t.Fatalf("unexpected body: %s", body)
}
@@ -158,13 +160,13 @@ func TestAttachmentReadDownloadSavesLargeAttachmentToDefaultFile(t *testing.T) {
flag.Host, flag.Token, flag.Version, flag.MaxInlineAttachmentBytes = origHost, origToken, origVersion, origInline
}()
res, err := attachmentReadFn(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
res, err := attachmentReadFn(context.Background(), map[string]any{
"method": "download", "owner": owner, "repo": repo, "issue_number": float64(42), "attachment_id": float64(1),
}}})
})
if err != nil {
t.Fatalf("attachmentReadFn() error = %v", err)
}
body := res.Content[0].(mcp.TextContent).Text
body := res.Content[0].(*mcp.TextContent).Text
wantPath := filepath.Join(home, ".gitea-mcp", "attachments", owner, repo, "large-uuid-1.bin")
if !strings.Contains(body, wantPath) {
t.Fatalf("result missing path %q: %s", wantPath, body)
@@ -180,3 +182,60 @@ func TestAttachmentReadDownloadSavesLargeAttachmentToDefaultFile(t *testing.T) {
t.Fatalf("result missing bytes: %s", body)
}
}
func TestAttachmentReadDownloadReturnsRawImageContent(t *testing.T) {
const uuid = "uuid-1"
payload := []byte{0, 1, 2, 250}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/attachments/"+uuid {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(payload)
}))
defer server.Close()
originalHost := flag.Host
originalLimit := flag.MaxInlineAttachmentBytes
flag.Host = server.URL
flag.MaxInlineAttachmentBytes = len(payload)
defer func() {
flag.Host = originalHost
flag.MaxInlineAttachmentBytes = originalLimit
}()
result, err := attachmentReadFn(context.Background(), map[string]any{
"method": "download",
"owner": "octo",
"repo": "demo",
"attachment_uuid": uuid,
})
if err != nil {
t.Fatalf("attachmentReadFn() error = %v", err)
}
if len(result.Content) != 2 {
t.Fatalf("content count = %d, want 2", len(result.Content))
}
if _, ok := result.Content[0].(*mcp.TextContent); !ok {
t.Fatalf("first content type = %T, want *mcp.TextContent", result.Content[0])
}
image, ok := result.Content[1].(*mcp.ImageContent)
if !ok {
t.Fatalf("second content type = %T, want *mcp.ImageContent", result.Content[1])
}
if image.MIMEType != "image/png" {
t.Errorf("image MIME type = %q, want image/png", image.MIMEType)
}
if !bytes.Equal(image.Data, payload) {
t.Errorf("image data = %v, want raw payload %v", image.Data, payload)
}
wire, err := json.Marshal(image)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
wantBase64 := base64.StdEncoding.EncodeToString(payload)
if !strings.Contains(string(wire), `"data":"`+wantBase64+`"`) {
t.Errorf("wire image = %s, want base64 data %q", wire, wantBase64)
}
}
+120 -124
View File
@@ -13,8 +13,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// issueWithAssets / commentWithAssets wrap the SDK types to capture the
@@ -38,125 +37,123 @@ const (
)
var (
ListRepoIssuesTool = mcp.NewTool(
ListRepoIssuesTool = tool.NewDefinition(
ListRepoIssuesToolName,
mcp.WithDescription("List issues in a repository (or pull requests, via the 'type' filter), filterable by state, labels, milestones, and update time range."),
mcp.WithToolAnnotation(annotation.ReadOnly("List repository issues")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("state", mcp.DefaultString("all")),
mcp.WithString("type", mcp.Description("issues or pulls"), mcp.Enum("issues", "pulls")),
mcp.WithArray("labels", mcp.Description("label name filter"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithArray("milestones", mcp.Description("milestone name or ID filter"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithString("since", mcp.Description("updated after ISO 8601")),
mcp.WithString("before", mcp.Description("updated before ISO 8601")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
"List issues in a repository (or pull requests, via the 'type' filter), filterable by state, labels, milestones, and update time range.",
annotation.ReadOnly("List repository issues"),
tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
tool.String("state", tool.Default("all")),
tool.String("type", tool.Description("issues or pulls"), tool.Enum("issues", "pulls")),
tool.Array("labels", tool.Description("label name filter"), tool.Items(map[string]any{"type": "string"})),
tool.Array("milestones", tool.Description("milestone name or ID filter"), tool.Items(map[string]any{"type": "string"})),
tool.String("since", tool.Description("updated after ISO 8601")),
tool.String("before", tool.Description("updated before ISO 8601")),
tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
)
IssueReadTool = mcp.NewTool(
IssueReadTool = tool.NewDefinition(
IssueReadToolName,
mcp.WithDescription("Read issue: details, comments, or labels."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read issue details")),
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_comments", "get_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("issue_number", mcp.Required()),
"Read issue: details, comments, or labels.",
annotation.ReadOnly("Read issue details"),
tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels")),
tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
tool.Number("issue_number", tool.Required()),
)
IssueWriteTool = mcp.NewTool(
IssueWriteTool = tool.NewDefinition(
IssueWriteToolName,
mcp.WithDescription("Write issues: create, update, manage comments and labels."),
mcp.WithToolAnnotation(annotation.Write("Create or update issues, comments, and labels")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("issue_number", mcp.Description("required except for 'create'")),
mcp.WithString("title", mcp.Description("required for 'create'")),
mcp.WithString("body", mcp.Description("required for 'create'/'add_comment'/'edit_comment'")),
mcp.WithArray("assignees", mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone"),
mcp.WithString("state", mcp.Enum("open", "closed", "all")),
mcp.WithNumber("commentID", mcp.Description("for 'edit_comment'")),
mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithNumber("label_id", mcp.Description("for 'remove_label'")),
mcp.WithString("ref", mcp.Description("branch to associate")),
mcp.WithString("deadline", mcp.Description("ISO 8601")),
mcp.WithBoolean("remove_deadline"),
"Write issues: create, update, manage comments and labels.",
annotation.Write("Create or update issues, comments, and labels"),
tool.String("method", tool.Required(), tool.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
tool.Number("issue_number", tool.Description("required except for 'create'")),
tool.String("title", tool.Description("required for 'create'")),
tool.String("body", tool.Description("required for 'create'/'add_comment'/'edit_comment'")),
tool.Array("assignees", tool.Items(map[string]any{"type": "string"})),
tool.Number("milestone"),
tool.String("state", tool.Enum("open", "closed", "all")),
tool.Number("commentID", tool.Description("for 'edit_comment'")),
tool.Array("labels", tool.Description("label IDs"), tool.Items(map[string]any{"type": "number"})),
tool.Number("label_id", tool.Description("for 'remove_label'")),
tool.String("ref", tool.Description("branch to associate")),
tool.String("deadline", tool.Description("ISO 8601")),
tool.Boolean("remove_deadline"),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool.RegisterRead(tool.ServerTool{
Tool: ListRepoIssuesTool,
Handler: listRepoIssuesFn,
})
Tool.RegisterRead(server.ServerTool{
Tool.RegisterRead(tool.ServerTool{
Tool: IssueReadTool,
Handler: issueReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool.RegisterWrite(tool.ServerTool{
Tool: IssueWriteTool,
Handler: issueWriteFn,
})
}
func issueReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
func issueReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "get":
return getIssueByIndexFn(ctx, req)
return getIssueByIndexFn(ctx, args)
case "get_comments":
return getIssueCommentsByIndexFn(ctx, req)
return getIssueCommentsByIndexFn(ctx, args)
case "get_labels":
return getIssueLabelsFn(ctx, req)
return getIssueLabelsFn(ctx, args)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func issueWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
func issueWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createIssueFn(ctx, req)
return createIssueFn(ctx, args)
case "update":
return editIssueFn(ctx, req)
return editIssueFn(ctx, args)
case "add_comment":
return createIssueCommentFn(ctx, req)
return createIssueCommentFn(ctx, args)
case "edit_comment":
return editIssueCommentFn(ctx, req)
return editIssueCommentFn(ctx, args)
case "add_labels":
return addIssueLabelsFn(ctx, req)
return addIssueLabelsFn(ctx, args)
case "remove_label":
return removeIssueLabelFn(ctx, req)
return removeIssueLabelFn(ctx, args)
case "replace_labels":
return replaceIssueLabelsFn(ctx, req)
return replaceIssueLabelsFn(ctx, args)
case "clear_labels":
return clearIssueLabelsFn(ctx, req)
return clearIssueLabelsFn(ctx, args)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func getIssueByIndexFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
index, err := params.GetIndex(args, "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -170,22 +167,22 @@ func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.TextResult(m)
}
func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func listRepoIssuesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
state, ok := req.GetArguments()["state"].(string)
state, ok := args["state"].(string)
if !ok {
state = "all"
}
labels := params.GetStringSlice(req.GetArguments(), "labels")
milestones := params.GetStringSlice(req.GetArguments(), "milestones")
page, pageSize := params.GetPagination(req.GetArguments(), 30)
labels := params.GetStringSlice(args, "labels")
milestones := params.GetStringSlice(args, "milestones")
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateType(state),
Labels: labels,
@@ -195,16 +192,16 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
PageSize: pageSize,
},
}
switch req.GetArguments()["type"] {
switch args["type"] {
case "issues":
opt.Type = gitea_sdk.IssueTypeIssue
case "pulls":
opt.Type = gitea_sdk.IssueTypePull
}
if t := params.GetOptionalTime(req.GetArguments(), "since"); t != nil {
if t := params.GetOptionalTime(args, "since"); t != nil {
opt.Since = *t
}
if t := params.GetOptionalTime(req.GetArguments(), "before"); t != nil {
if t := params.GetOptionalTime(args, "before"); t != nil {
opt.Before = *t
}
client, err := gitea.ClientFromContext(ctx)
@@ -218,20 +215,20 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slimIssues(issues))
}
func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func createIssueFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
title, err := params.GetString(req.GetArguments(), "title")
title, err := params.GetString(args, "title")
if err != nil {
return to.ErrorResult(err)
}
body, err := params.GetString(req.GetArguments(), "body")
body, err := params.GetString(args, "body")
if err != nil {
return to.ErrorResult(err)
}
@@ -243,19 +240,19 @@ func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
Title: title,
Body: body,
}
opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees")
if val, exists := req.GetArguments()["milestone"]; exists {
opt.Assignees = params.GetStringSlice(args, "assignees")
if val, exists := args["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = milestone
}
}
if labelIDs, err := params.GetInt64Slice(req.GetArguments(), "labels"); err == nil {
if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
opt.Labels = labelIDs
}
if ref, ok := req.GetArguments()["ref"].(string); ok {
if ref, ok := args["ref"].(string); ok {
opt.Ref = ref
}
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline")
opt.Deadline = params.GetOptionalTime(args, "deadline")
issue, _, err := client.Issues.CreateIssue(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
@@ -264,20 +261,20 @@ func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
return to.TextResult(slimIssue(issue))
}
func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func createIssueCommentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
index, err := params.GetIndex(args, "issue_number")
if err != nil {
return to.ErrorResult(err)
}
body, err := params.GetString(req.GetArguments(), "body")
body, err := params.GetString(args, "body")
if err != nil {
return to.ErrorResult(err)
}
@@ -296,21 +293,20 @@ func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.TextResult(slimComment(issueComment))
}
func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func editIssueFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
index, err := params.GetIndex(args, "issue_number")
if err != nil {
return to.ErrorResult(err)
}
args := req.GetArguments()
opt := gitea_sdk.EditIssueOption{
Body: params.GetPresentStringPtr(args, "body"),
Ref: params.GetPresentStringPtr(args, "ref"),
@@ -343,20 +339,20 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
return to.TextResult(slimIssue(issue))
}
func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func editIssueCommentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
commentID, err := params.GetIndex(req.GetArguments(), "commentID")
commentID, err := params.GetIndex(args, "commentID")
if err != nil {
return to.ErrorResult(err)
}
body, err := params.GetString(req.GetArguments(), "body")
body, err := params.GetString(args, "body")
if err != nil {
return to.ErrorResult(err)
}
@@ -375,16 +371,16 @@ func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.TextResult(slimComment(issueComment))
}
func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func getIssueCommentsByIndexFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
index, err := params.GetIndex(args, "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -402,16 +398,16 @@ func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.TextResult(out)
}
func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func getIssueLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
index, err := params.GetIndex(args, "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -427,20 +423,20 @@ func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slim.Labels(labels))
}
func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func addIssueLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
index, err := params.GetIndex(args, "issue_number")
if err != nil {
return to.ErrorResult(err)
}
labels, err := params.GetInt64Slice(req.GetArguments(), "labels")
labels, err := params.GetInt64Slice(args, "labels")
if err != nil {
return to.ErrorResult(err)
}
@@ -456,20 +452,20 @@ func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slim.Labels(issueLabels))
}
func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func replaceIssueLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
index, err := params.GetIndex(args, "issue_number")
if err != nil {
return to.ErrorResult(err)
}
labels, err := params.GetInt64Slice(req.GetArguments(), "labels")
labels, err := params.GetInt64Slice(args, "labels")
if err != nil {
return to.ErrorResult(err)
}
@@ -485,16 +481,16 @@ func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.TextResult(slim.Labels(issueLabels))
}
func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func clearIssueLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
index, err := params.GetIndex(args, "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -510,20 +506,20 @@ func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.TextResult("Labels cleared successfully")
}
func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
func removeIssueLabelFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
index, err := params.GetIndex(args, "issue_number")
if err != nil {
return to.ErrorResult(err)
}
labelID, err := params.GetIndex(req.GetArguments(), "label_id")
labelID, err := params.GetIndex(args, "label_id")
if err != nil {
return to.ErrorResult(err)
}
+29 -37
View File
@@ -12,7 +12,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func Test_listRepoIssuesFn_filters(t *testing.T) {
@@ -60,20 +60,16 @@ func Test_listRepoIssuesFn_filters(t *testing.T) {
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"type": "issues",
"labels": []any{"bug", "enhancement"},
"milestones": []any{"v1.0", "2"},
"since": "2026-01-01T00:00:00Z",
},
},
args := map[string]any{
"owner": owner,
"repo": repo,
"type": "issues",
"labels": []any{"bug", "enhancement"},
"milestones": []any{"v1.0", "2"},
"since": "2026-01-01T00:00:00Z",
}
_, err := listRepoIssuesFn(context.Background(), req)
_, err := listRepoIssuesFn(context.Background(), args)
if err != nil {
t.Fatalf("listRepoIssuesFn() error = %v", err)
}
@@ -126,17 +122,17 @@ func Test_listRepoIssuesFn_includesMilestone(t *testing.T) {
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
args := map[string]any{
"owner": owner, "repo": repo,
}}}
res, err := listRepoIssuesFn(context.Background(), req)
}
res, err := listRepoIssuesFn(context.Background(), args)
if err != nil {
t.Fatalf("listRepoIssuesFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(mcp.TextContent).Text
body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `"milestone"`) || !strings.Contains(body, `"v1.0"`) {
t.Fatalf("expected milestone in list output, got: %s", body)
}
@@ -189,20 +185,16 @@ func Test_createIssueFn_labels(t *testing.T) {
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"title": "test issue",
"body": "body",
"labels": []any{float64(10), float64(20)},
"deadline": "2026-06-01T00:00:00Z",
},
},
args := map[string]any{
"owner": owner,
"repo": repo,
"title": "test issue",
"body": "body",
"labels": []any{float64(10), float64(20)},
"deadline": "2026-06-01T00:00:00Z",
}
_, err := createIssueFn(context.Background(), req)
_, err := createIssueFn(context.Background(), args)
if err != nil {
t.Fatalf("createIssueFn() error = %v", err)
}
@@ -255,17 +247,17 @@ func Test_getIssueByIndexFn_includesAttachments(t *testing.T) {
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
args := map[string]any{
"owner": owner, "repo": repo, "issue_number": float64(42),
}}}
res, err := getIssueByIndexFn(context.Background(), req)
}
res, err := getIssueByIndexFn(context.Background(), args)
if err != nil {
t.Fatalf("getIssueByIndexFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(mcp.TextContent).Text
body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `[shot.png](https://example/shot.png)`) {
t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
}
@@ -304,17 +296,17 @@ func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) {
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
args := map[string]any{
"owner": owner, "repo": repo, "issue_number": float64(7),
}}}
res, err := getIssueCommentsByIndexFn(context.Background(), req)
}
res, err := getIssueCommentsByIndexFn(context.Background(), args)
if err != nil {
t.Fatalf("getIssueCommentsByIndexFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(mcp.TextContent).Text
body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `[log.txt](https://example/log.txt)`) {
t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
}