diff --git a/Makefile b/Makefile index 6a22af5..e28ea73 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,8 @@ LDFLAGS := -X "main.Version=$(VERSION)" GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go +GOTEST_FLAGS ?= -race -timeout 20m + .PHONY: help help: ## print this help message @echo "Usage: make [target]" @@ -40,7 +42,7 @@ build: ## build the application .PHONY: test test: ## run Go tests - $(GO) test ./... + $(GO) test $(GOTEST_FLAGS) ./... .PHONY: air air: ## install air for hot reload diff --git a/operation/operation.go b/operation/operation.go index dc4b2c9..5ef1a37 100644 --- a/operation/operation.go +++ b/operation/operation.go @@ -32,6 +32,15 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +// maxRequestBodyBytes raises the SDK's 4 MiB default, which is too tight for the +// base64 file content create_or_update_file accepts. +const maxRequestBodyBytes = 32 << 20 + +// sessionTimeout expires idle sessions, which the SDK otherwise keeps for the +// process lifetime: a client that goes away without DELETE /mcp leaks its +// session, and initialize takes no token. Clients re-initialize on the 404. +const sessionTimeout = 30 * time.Minute + var ( mcpServer *mcp.Server @@ -95,24 +104,18 @@ func authTokenMiddleware(next mcp.MethodHandler) mcp.MethodHandler { } } -func newStreamableHTTPHandler(s *mcp.Server) http.Handler { - return mcp.NewStreamableHTTPHandler( +func newHTTPServer(addr string, s *mcp.Server) *http.Server { + mux := http.NewServeMux() + mux.Handle("/mcp", mcp.NewStreamableHTTPHandler( func(*http.Request) *mcp.Server { return s }, &mcp.StreamableHTTPOptions{ Logger: log.Slog(), - MaxRequestBodyBytes: -1, - Stateless: false, + MaxRequestBodyBytes: maxRequestBodyBytes, + Stateless: false, // PR 2 switches this on + SessionTimeout: sessionTimeout, }, - ) -} - -func newHTTPServer(addr string, s *mcp.Server) *http.Server { - mux := http.NewServeMux() - mux.Handle("/mcp", newStreamableHTTPHandler(s)) - return &http.Server{ - Addr: addr, - Handler: mux, - } + )) + return &http.Server{Addr: addr, Handler: mux} } func Run() error { diff --git a/operation/operation_test.go b/operation/operation_test.go index 8e0b3cf..6c97c77 100644 --- a/operation/operation_test.go +++ b/operation/operation_test.go @@ -1,53 +1,6 @@ package operation -import ( - "testing" - - "gitea.com/gitea/gitea-mcp/pkg/flag" -) - -// TestAllToolsHaveDescriptions ensures every registered tool sets a non-empty -// Tool.Description, as strict MCP clients reject tools without one. -func TestAllToolsHaveDescriptions(t *testing.T) { - origRO, origAllow := flag.ReadOnly, flag.AllowedTools - t.Cleanup(func() { - flag.ReadOnly, flag.AllowedTools = origRO, origAllow - }) - flag.ReadOnly = false - flag.AllowedTools = nil - - var missing []string - for _, d := range domainTools { - for _, st := range d.Tools() { - if st.Tool.Description == "" { - missing = append(missing, st.Tool.Name) - } - } - } - if len(missing) > 0 { - t.Errorf("tools missing a description: %v", missing) - } -} - -// TestDomainToolsScopesAreUniqueAndNonEmpty ensures every entry registered in -// domainTools has a canonical, non-empty scope name and that no two domains -// share the same scope (each domain.Tools() call is filtered by exactly one -// scope name via flag.AllowedScopes). -func TestDomainToolsScopesAreUniqueAndNonEmpty(t *testing.T) { - seen := map[string]struct{}{} - for _, d := range domainTools { - scope := d.Scope() - if scope == "" { - t.Errorf("domainTools contains a domain with an empty scope") - continue - } - if _, ok := seen[scope]; ok { - t.Errorf("domainTools contains a duplicate scope %q", scope) - continue - } - seen[scope] = struct{}{} - } -} +import "testing" func TestParseAuthToken(t *testing.T) { tests := []struct { diff --git a/operation/sdk_integration_test.go b/operation/sdk_integration_test.go index daac001..20cebfb 100644 --- a/operation/sdk_integration_test.go +++ b/operation/sdk_integration_test.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "net" + "io" "net/http" "net/http/httptest" "os" @@ -41,28 +41,39 @@ func exposeAllTools(t *testing.T) { flag.Version = testServerVersion } -func assertVersionToolResult(t *testing.T, result *mcp.CallToolResult) { +// registeredToolCount is what the registry exposes under the current flags, so +// the transport assertions track tool additions without being edited. +func registeredToolCount() int { + count := 0 + for _, domain := range domainTools { + count += len(domain.Tools()) + } + return count +} + +func textContent(t *testing.T, result *mcp.CallToolResult) string { t.Helper() if len(result.Content) != 1 { - t.Fatalf("version tool content count = %d, want 1", len(result.Content)) + t.Fatalf("content count = %d, want 1", len(result.Content)) } content, ok := result.Content[0].(*mcp.TextContent) if !ok { - t.Fatalf("version tool content type = %T, want *mcp.TextContent", result.Content[0]) - } - if !strings.Contains(content.Text, testServerVersion) { - t.Errorf("version tool result = %q, want it to contain %q", content.Text, testServerVersion) + t.Fatalf("content type = %T, want *mcp.TextContent", result.Content[0]) } + return content.Text } -func listAndCallVersion(ctx context.Context, t *testing.T, session *mcp.ClientSession) { +// listAndCallVersion is the round trip every transport must support. wantText +// differs per transport: the stdio subprocess resolves its version from the VCS +// build info (main.go:14), so only the in-process servers have a known one. +func listAndCallVersion(ctx context.Context, t *testing.T, session *mcp.ClientSession, wantText string) { t.Helper() result, err := session.ListTools(ctx, nil) if err != nil { t.Fatalf("ListTools() error = %v", err) } - if len(result.Tools) != 54 { - t.Fatalf("ListTools() count = %d, want 54", len(result.Tools)) + if want := registeredToolCount(); len(result.Tools) != want { + t.Fatalf("ListTools() count = %d, want %d", len(result.Tools), want) } callResult, err := session.CallTool(ctx, &mcp.CallToolParams{ Name: "get_gitea_mcp_server_version", @@ -70,7 +81,9 @@ func listAndCallVersion(ctx context.Context, t *testing.T, session *mcp.ClientSe if err != nil { t.Fatalf("CallTool() error = %v", err) } - assertVersionToolResult(t, callResult) + if got := textContent(t, callResult); !strings.Contains(got, wantText) { + t.Errorf("version tool result = %q, want it to contain %q", got, wantText) + } } func TestOfficialSDKInMemory(t *testing.T) { @@ -94,7 +107,7 @@ func TestOfficialSDKInMemory(t *testing.T) { if got := session.InitializeResult().ProtocolVersion; got != "2026-07-28" { t.Errorf("protocol version = %q, want %q", got, "2026-07-28") } - listAndCallVersion(ctx, t, session) + listAndCallVersion(ctx, t, session, testServerVersion) if err := session.Close(); err != nil { t.Fatalf("Close() error = %v", err) } @@ -132,7 +145,7 @@ func TestStreamableHTTPStateful(t *testing.T) { if got := session.InitializeResult().ProtocolVersion; got != "2025-11-25" { t.Errorf("protocol version = %q, want %q", got, "2025-11-25") } - listAndCallVersion(ctx, t, session) + listAndCallVersion(ctx, t, session, testServerVersion) response, err := httpTestServer.Client().Get(httpTestServer.URL + "/not-mcp") if err != nil { @@ -144,25 +157,47 @@ func TestStreamableHTTPStateful(t *testing.T) { } } -func TestStreamableHTTPAllowsLegacyLargeBodies(t *testing.T) { +// spaceReader yields an endless run of spaces, so oversized bodies can be sent +// without allocating them. +type spaceReader struct{} + +func (spaceReader) Read(p []byte) (int, error) { + for index := range p { + p[index] = ' ' + } + return len(p), nil +} + +func TestStreamableHTTPRequestBodyLimit(t *testing.T) { server := newMCPServer(testServerVersion) httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler) defer httpTestServer.Close() - body := strings.NewReader(strings.Repeat(" ", mcp.DefaultMaxRequestBodyBytes+1)) - request, err := http.NewRequest(http.MethodPost, httpTestServer.URL+"/mcp", body) - if err != nil { - t.Fatalf("NewRequest() error = %v", err) - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Accept", "application/json, text/event-stream") - response, err := httpTestServer.Client().Do(request) - if err != nil { - t.Fatalf("POST large body error = %v", err) - } - defer response.Body.Close() - if response.StatusCode == http.StatusRequestEntityTooLarge { - t.Errorf("POST large body status = %d; PR 1 must preserve the previous unlimited body behavior", response.StatusCode) + for _, test := range []struct { + name string + size int64 + tooLarge bool + }{ + {name: "above the SDK default", size: mcp.DefaultMaxRequestBodyBytes + 1}, + {name: "above our own limit", size: maxRequestBodyBytes + 1, tooLarge: true}, + } { + t.Run(test.name, func(t *testing.T) { + request, err := http.NewRequest(http.MethodPost, httpTestServer.URL+"/mcp", io.LimitReader(spaceReader{}, test.size)) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + request.ContentLength = test.size + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json, text/event-stream") + response, err := httpTestServer.Client().Do(request) + if err != nil { + t.Fatalf("POST %d bytes error = %v", test.size, err) + } + defer response.Body.Close() + if gotTooLarge := response.StatusCode == http.StatusRequestEntityTooLarge; gotTooLarge != test.tooLarge { + t.Errorf("POST %d bytes status = %d, want %d = %v", test.size, response.StatusCode, http.StatusRequestEntityTooLarge, test.tooLarge) + } + }) } } @@ -179,8 +214,7 @@ func (t *authorizationTransport) set(value string) { } func (t *authorizationTransport) RoundTrip(request *http.Request) (*http.Response, error) { - clone := request.Clone(request.Context()) - clone.Header = request.Header.Clone() + clone := request.Clone(request.Context()) // Clone already copies the header t.mu.RLock() value := t.value t.mu.RUnlock() @@ -313,6 +347,7 @@ func TestStdioCommandTransport(t *testing.T) { if testing.Short() { t.Skip("skipping subprocess build in short mode") } + exposeAllTools(t) // the subprocess runs with default flags, so match them here ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -336,58 +371,5 @@ func TestStdioCommandTransport(t *testing.T) { if got := session.InitializeResult().ProtocolVersion; got != "2026-07-28" { t.Errorf("protocol version = %q, want %q", got, "2026-07-28") } - result, err := session.ListTools(ctx, nil) - if err != nil { - t.Fatalf("ListTools() error = %v", err) - } - if len(result.Tools) != 54 { - t.Errorf("ListTools() count = %d, want 54", len(result.Tools)) - } - callResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "get_gitea_mcp_server_version"}) - if err != nil { - t.Fatalf("CallTool() error = %v", err) - } - content, ok := callResult.Content[0].(*mcp.TextContent) - if !ok { - t.Fatalf("CallTool() content type = %T, want *mcp.TextContent", callResult.Content[0]) - } - if !strings.Contains(content.Text, "Gitea MCP Server version:") { - t.Errorf("CallTool() result = %q, want server version", content.Text) - } -} - -func TestNewHTTPServerAddress(t *testing.T) { - server := newHTTPServer(":12345", newMCPServer(testServerVersion)) - if server.Addr != ":12345" { - t.Errorf("server address = %q, want %q", server.Addr, ":12345") - } - if server.Handler == nil { - t.Error("server handler is nil") - } -} - -func TestHTTPServerGracefulShutdown(t *testing.T) { - server := newHTTPServer("127.0.0.1:0", newMCPServer(testServerVersion)) - listener, err := net.Listen("tcp", server.Addr) - if err != nil { - t.Fatalf("Listen() error = %v", err) - } - serveDone := make(chan error, 1) - go func() { - serveDone <- server.Serve(listener) - }() - - shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - if err := server.Shutdown(shutdownCtx); err != nil { - t.Fatalf("Shutdown() error = %v", err) - } - select { - case err := <-serveDone: - if !errors.Is(err, http.ErrServerClosed) { - t.Errorf("Serve() error = %v, want http.ErrServerClosed", err) - } - case <-shutdownCtx.Done(): - t.Fatal("server did not stop after Shutdown()") - } + listAndCallVersion(ctx, t, session, "Gitea MCP Server version:") } diff --git a/operation/testdata/tools.golden.json b/operation/testdata/tools.golden.json deleted file mode 100644 index 00e2fc8..0000000 --- a/operation/testdata/tools.golden.json +++ /dev/null @@ -1,2817 +0,0 @@ -[ - { - "scope": "actions", - "access": "read", - "name": "actions_config_read", - "description": "Read Actions secrets and variables.", - "inputSchema": { - "properties": { - "method": { - "enum": [ - "list_repo_secrets", - "list_org_secrets", - "list_repo_variables", - "get_repo_variable", - "list_org_variables", - "get_org_variable" - ], - "type": "string" - }, - "name": { - "description": "for get methods", - "type": "string" - }, - "org": { - "description": "for org methods", - "type": "string" - }, - "owner": { - "description": "for repo methods", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "minimum": 1, - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "minimum": 1, - "type": "number" - }, - "repo": { - "description": "for repo methods", - "type": "string" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "annotations": { - "title": "Read Actions secrets and variables", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "actions", - "access": "read", - "name": "actions_run_read", - "description": "Read Actions workflows, runs, jobs, logs, and artifacts.", - "inputSchema": { - "properties": { - "artifact_id": { - "description": "for 'get_artifact'/'download_artifact'", - "type": "number" - }, - "artifact_name": { - "description": "name filter for 'list_artifacts'/'list_run_artifacts'", - "type": "string" - }, - "job_id": { - "description": "for 'get_job'/log methods", - "type": "number" - }, - "max_bytes": { - "default": 65536, - "description": "max log bytes", - "minimum": 1024, - "type": "number" - }, - "method": { - "enum": [ - "list_workflows", - "get_workflow", - "list_runs", - "get_run", - "list_jobs", - "list_run_jobs", - "get_job", - "get_job_log_preview", - "download_job_log", - "list_artifacts", - "list_run_artifacts", - "get_artifact", - "download_artifact" - ], - "type": "string" - }, - "output_path": { - "description": "for 'download_job_log'/'download_artifact'", - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "minimum": 1, - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "minimum": 1, - "type": "number" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "run_id": { - "description": "for 'get_run'/'list_run_jobs'/'list_run_artifacts'", - "type": "number" - }, - "status": { - "description": "filter for 'list_runs'/'list_jobs'", - "type": "string" - }, - "tail_lines": { - "default": 200, - "description": "log tail lines", - "minimum": 1, - "type": "number" - }, - "workflow_id": { - "description": "ID or filename (for 'get_workflow')", - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Read Actions workflow, run, job, and artifact data", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "actions", - "access": "write", - "name": "actions_config_write", - "description": "Write Actions secrets and variables: upsert, create, update, delete.", - "inputSchema": { - "properties": { - "data": { - "description": "secret value (upsert)", - "type": "string" - }, - "description": { - "type": "string" - }, - "method": { - "enum": [ - "upsert_repo_secret", - "delete_repo_secret", - "upsert_org_secret", - "delete_org_secret", - "create_repo_variable", - "update_repo_variable", - "delete_repo_variable", - "create_org_variable", - "update_org_variable", - "delete_org_variable" - ], - "type": "string" - }, - "name": { - "description": "secret or variable name", - "type": "string" - }, - "org": { - "description": "for org methods", - "type": "string" - }, - "owner": { - "description": "for repo methods", - "type": "string" - }, - "repo": { - "description": "for repo methods", - "type": "string" - }, - "value": { - "description": "variable value", - "type": "string" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "annotations": { - "title": "Manage Actions secrets and variables", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "actions", - "access": "write", - "name": "actions_run_write", - "description": "Write Actions runs: dispatch, cancel, rerun.", - "inputSchema": { - "properties": { - "inputs": { - "description": "for 'dispatch_workflow'", - "properties": {}, - "type": "object" - }, - "method": { - "enum": [ - "dispatch_workflow", - "cancel_run", - "rerun_run" - ], - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "ref": { - "description": "branch or tag (for 'dispatch_workflow')", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "run_id": { - "description": "for 'cancel_run'/'rerun_run'", - "type": "number" - }, - "workflow_id": { - "description": "ID or filename (for 'dispatch_workflow')", - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Trigger, cancel, or rerun Actions workflows", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "branch", - "access": "read", - "name": "list_branches", - "description": "List all branches in a repository, paginated.", - "inputSchema": { - "properties": { - "owner": { - "description": "repo owner", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "List repository branches", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "branch", - "access": "write", - "name": "create_branch", - "description": "Create a new branch in a repository, optionally from a specific source branch (defaults to the repository's default branch).", - "inputSchema": { - "properties": { - "branch": { - "type": "string" - }, - "old_branch": { - "description": "source branch (default: repo default)", - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "branch" - ], - "type": "object" - }, - "annotations": { - "title": "Create a new branch", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "branch", - "access": "write", - "name": "delete_branch", - "description": "Permanently delete a branch from a repository. This action is destructive and cannot be undone.", - "inputSchema": { - "properties": { - "branch": { - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "branch" - ], - "type": "object" - }, - "annotations": { - "title": "Delete a branch", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "commit", - "access": "read", - "name": "get_commit", - "description": "Get details for a single commit in a repository by its SHA.", - "inputSchema": { - "properties": { - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "sha": { - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "sha" - ], - "type": "object" - }, - "annotations": { - "title": "Get commit details", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "commit", - "access": "read", - "name": "list_commits", - "description": "List commits in a repository, optionally starting from a specific branch or SHA and filtered to commits touching a given file path.", - "inputSchema": { - "properties": { - "owner": { - "description": "repo owner", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "minimum": 1, - "type": "number" - }, - "path": { - "description": "only commits touching this path", - "type": "string" - }, - "per_page": { - "default": 30, - "description": "results per page", - "minimum": 1, - "type": "number" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "sha": { - "description": "starting SHA or branch", - "type": "string" - } - }, - "required": [ - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "List repository commits", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "file", - "access": "read", - "name": "get_dir_contents", - "description": "List the entries (files and subdirectories) in a repository directory at a given ref (branch, tag, or commit SHA).", - "inputSchema": { - "properties": { - "owner": { - "description": "repo owner", - "type": "string" - }, - "path": { - "type": "string" - }, - "ref": { - "description": "branch, tag, or commit SHA", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "ref", - "path" - ], - "type": "object" - }, - "annotations": { - "title": "Get directory contents", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "file", - "access": "read", - "name": "get_file_contents", - "description": "Get file content and metadata", - "inputSchema": { - "properties": { - "owner": { - "description": "repo owner", - "type": "string" - }, - "path": { - "type": "string" - }, - "ref": { - "description": "branch, tag, or commit SHA", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "withLines": { - "description": "return numbered lines", - "type": "boolean" - } - }, - "required": [ - "owner", - "repo", - "ref", - "path" - ], - "type": "object" - }, - "annotations": { - "title": "Get file content", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "file", - "access": "write", - "name": "create_or_update_file", - "description": "Create or update a file (provide sha to update an existing file).", - "inputSchema": { - "properties": { - "branch_name": { - "type": "string" - }, - "content": { - "type": "string" - }, - "message": { - "description": "commit message", - "type": "string" - }, - "new_branch_name": { - "description": "new branch (create only)", - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "path": { - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "sha": { - "description": "existing file SHA (omit to create)", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "path", - "content", - "message", - "branch_name" - ], - "type": "object" - }, - "annotations": { - "title": "Create or update a file", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "file", - "access": "write", - "name": "delete_file", - "description": "Delete a file from a repository by committing the removal to a branch. Requires the file's current SHA and a commit message.", - "inputSchema": { - "properties": { - "branch_name": { - "type": "string" - }, - "message": { - "description": "commit message", - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "path": { - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "sha": { - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "path", - "message", - "branch_name", - "sha" - ], - "type": "object" - }, - "annotations": { - "title": "Delete a file", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "issue", - "access": "read", - "name": "attachment_read", - "description": "Read issue/comment attachments: list metadata, get metadata, or download content.", - "inputSchema": { - "properties": { - "attachment_id": { - "description": "required for get and for download when attachment_uuid is not provided", - "type": "number" - }, - "attachment_uuid": { - "description": "attachment UUID for direct download path lookup", - "type": "string" - }, - "comment_id": { - "description": "required for comment attachment list/get or comment-scoped metadata lookup", - "type": "number" - }, - "issue_number": { - "description": "required for issue attachment list/get or issue-scoped metadata lookup", - "type": "number" - }, - "method": { - "enum": [ - "list", - "get", - "download" - ], - "type": "string" - }, - "output_path": { - "description": "write the attachment to this exact path", - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Read issue or comment attachments", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "issue", - "access": "read", - "name": "issue_read", - "description": "Read issue: details, comments, or labels.", - "inputSchema": { - "properties": { - "issue_number": { - "type": "number" - }, - "method": { - "enum": [ - "get", - "get_comments", - "get_labels" - ], - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo", - "issue_number" - ], - "type": "object" - }, - "annotations": { - "title": "Read issue details", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "issue", - "access": "read", - "name": "list_issues", - "description": "List issues in a repository (or pull requests, via the 'type' filter), filterable by state, labels, milestones, and update time range.", - "inputSchema": { - "properties": { - "before": { - "description": "updated before ISO 8601", - "type": "string" - }, - "labels": { - "description": "label name filter", - "items": { - "type": "string" - }, - "type": "array" - }, - "milestones": { - "description": "milestone name or ID filter", - "items": { - "type": "string" - }, - "type": "array" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "since": { - "description": "updated after ISO 8601", - "type": "string" - }, - "state": { - "default": "all", - "type": "string" - }, - "type": { - "description": "issues or pulls", - "enum": [ - "issues", - "pulls" - ], - "type": "string" - } - }, - "required": [ - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "List repository issues", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "issue", - "access": "write", - "name": "issue_write", - "description": "Write issues: create, update, manage comments and labels.", - "inputSchema": { - "properties": { - "assignees": { - "items": { - "type": "string" - }, - "type": "array" - }, - "body": { - "description": "required for 'create'/'add_comment'/'edit_comment'", - "type": "string" - }, - "commentID": { - "description": "for 'edit_comment'", - "type": "number" - }, - "deadline": { - "description": "ISO 8601", - "type": "string" - }, - "issue_number": { - "description": "required except for 'create'", - "type": "number" - }, - "label_id": { - "description": "for 'remove_label'", - "type": "number" - }, - "labels": { - "description": "label IDs", - "items": { - "type": "number" - }, - "type": "array" - }, - "method": { - "enum": [ - "create", - "update", - "add_comment", - "edit_comment", - "add_labels", - "remove_label", - "replace_labels", - "clear_labels" - ], - "type": "string" - }, - "milestone": { - "type": "number" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "ref": { - "description": "branch to associate", - "type": "string" - }, - "remove_deadline": { - "type": "boolean" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "state": { - "enum": [ - "open", - "closed", - "all" - ], - "type": "string" - }, - "title": { - "description": "required for 'create'", - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Create or update issues, comments, and labels", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "label", - "access": "read", - "name": "label_read", - "description": "Read repo or org labels.", - "inputSchema": { - "properties": { - "id": { - "description": "label ID (for 'get_repo_label')", - "type": "number" - }, - "method": { - "enum": [ - "list_repo_labels", - "get_repo_label", - "list_org_labels" - ], - "type": "string" - }, - "org": { - "description": "for org methods", - "type": "string" - }, - "owner": { - "description": "for repo methods", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "repo": { - "description": "for repo methods", - "type": "string" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "annotations": { - "title": "Read labels", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "label", - "access": "write", - "name": "label_write", - "description": "Write labels (repo or org): create, edit, delete.", - "inputSchema": { - "properties": { - "color": { - "description": "hex (#RRGGBB); required for create", - "type": "string" - }, - "description": { - "type": "string" - }, - "exclusive": { - "description": "exclusive (org only)", - "type": "boolean" - }, - "id": { - "description": "for edit/delete", - "type": "number" - }, - "is_archived": { - "description": "archived (repo only)", - "type": "boolean" - }, - "method": { - "enum": [ - "create_repo_label", - "edit_repo_label", - "delete_repo_label", - "create_org_label", - "edit_org_label", - "delete_org_label" - ], - "type": "string" - }, - "name": { - "description": "required for create", - "type": "string" - }, - "org": { - "description": "for org methods", - "type": "string" - }, - "owner": { - "description": "for repo methods", - "type": "string" - }, - "repo": { - "description": "for repo methods", - "type": "string" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "annotations": { - "title": "Create, update, or delete labels", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "milestone", - "access": "read", - "name": "milestone_read", - "description": "Read milestones: get one or list.", - "inputSchema": { - "properties": { - "id": { - "description": "for 'get'", - "type": "number" - }, - "method": { - "enum": [ - "get", - "list" - ], - "type": "string" - }, - "name": { - "description": "name filter (for 'list')", - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "state": { - "default": "all", - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Read milestones", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "milestone", - "access": "write", - "name": "milestone_write", - "description": "Write milestones: create, update, delete.", - "inputSchema": { - "properties": { - "description": { - "type": "string" - }, - "due_on": { - "description": "due date", - "type": "string" - }, - "id": { - "description": "for 'update'/'delete'", - "type": "number" - }, - "method": { - "enum": [ - "create", - "update", - "edit", - "delete" - ], - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "state": { - "enum": [ - "open", - "closed" - ], - "type": "string" - }, - "title": { - "description": "for 'create'", - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Create, update, or delete milestones", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "notification", - "access": "read", - "name": "notification_read", - "description": "Read notifications: list (optionally scoped to a repo) or get a thread by ID.", - "inputSchema": { - "properties": { - "before": { - "description": "updated before ISO 8601", - "type": "string" - }, - "id": { - "description": "thread ID (for 'get')", - "type": "number" - }, - "method": { - "enum": [ - "list", - "get" - ], - "type": "string" - }, - "owner": { - "description": "scope 'list' to a repo", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "repo": { - "description": "scope 'list' to a repo", - "type": "string" - }, - "since": { - "description": "updated after ISO 8601", - "type": "string" - }, - "status": { - "enum": [ - "unread", - "read", - "pinned" - ], - "type": "string" - }, - "subject_type": { - "enum": [ - "Issue", - "Pull", - "Commit", - "Repository" - ], - "type": "string" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "annotations": { - "title": "Read notifications", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "notification", - "access": "write", - "name": "notification_write", - "description": "Mark a notification or all notifications as read.", - "inputSchema": { - "properties": { - "id": { - "description": "thread ID (for 'mark_read')", - "type": "number" - }, - "last_read_at": { - "description": "ISO 8601; defaults to now", - "type": "string" - }, - "method": { - "enum": [ - "mark_read", - "mark_all_read" - ], - "type": "string" - }, - "owner": { - "description": "scope 'mark_all_read' to a repo", - "type": "string" - }, - "repo": { - "description": "scope 'mark_all_read' to a repo", - "type": "string" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "annotations": { - "title": "Manage notifications", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "packages", - "access": "read", - "name": "package_read", - "description": "Read package registry: list packages (one entry per version, filter via 'q'/'type'), list versions, or get a version.", - "inputSchema": { - "properties": { - "method": { - "enum": [ - "list", - "list_versions", - "get" - ], - "type": "string" - }, - "name": { - "description": "slashes auto-encoded; required except 'list'", - "type": "string" - }, - "owner": { - "description": "user or org", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "minimum": 1, - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "minimum": 1, - "type": "number" - }, - "q": { - "description": "search query", - "type": "string" - }, - "type": { - "description": "container/npm/maven/pypi/cargo/generic; required except 'list'", - "type": "string" - }, - "version": { - "description": "for 'get'", - "type": "string" - } - }, - "required": [ - "method", - "owner" - ], - "type": "object" - }, - "annotations": { - "title": "Read package registry", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "packages", - "access": "write", - "name": "package_write", - "description": "Delete a package version (irreversible).", - "inputSchema": { - "properties": { - "method": { - "enum": [ - "delete" - ], - "type": "string" - }, - "name": { - "description": "slashes auto-encoded", - "type": "string" - }, - "owner": { - "description": "user or org", - "type": "string" - }, - "type": { - "description": "container/npm/maven/pypi/cargo/generic", - "type": "string" - }, - "version": { - "type": "string" - } - }, - "required": [ - "method", - "owner", - "type", - "name", - "version" - ], - "type": "object" - }, - "annotations": { - "title": "Delete a package version", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "pull_request", - "access": "read", - "name": "list_pull_requests", - "description": "List pull requests in a repository, filterable by state and milestone, with configurable sort order (e.g. recently updated, most commented).", - "inputSchema": { - "properties": { - "milestone": { - "type": "number" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "sort": { - "default": "recentupdate", - "enum": [ - "oldest", - "recentupdate", - "leastupdate", - "mostcomment", - "leastcomment", - "priority" - ], - "type": "string" - }, - "state": { - "default": "all", - "enum": [ - "open", - "closed", - "all" - ], - "type": "string" - } - }, - "required": [ - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "List pull requests", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "pull_request", - "access": "read", - "name": "pull_request_read", - "description": "Read pull request: details, diff, changed files, head commit status, reviews, review comments.", - "inputSchema": { - "properties": { - "binary": { - "description": "include binary diff", - "type": "boolean" - }, - "method": { - "enum": [ - "get", - "get_diff", - "get_files", - "get_status", - "get_reviews", - "get_review", - "get_review_comments" - ], - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "pull_number": { - "type": "number" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "review_id": { - "description": "for 'get_review'; optional for 'get_review_comments', omit to list all", - "type": "number" - } - }, - "required": [ - "method", - "owner", - "repo", - "pull_number" - ], - "type": "object" - }, - "annotations": { - "title": "Read pull request details", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "pull_request", - "access": "write", - "name": "pull_request_review_write", - "description": "Write PR reviews: create, submit, delete, dismiss, reply to and resolve review comments.", - "inputSchema": { - "properties": { - "body": { - "description": "review body, or reply text for 'reply_comment'", - "type": "string" - }, - "comment_id": { - "description": "comment ID from 'get_review_comments'; resolve takes the thread's first", - "type": "number" - }, - "comments": { - "description": "inline comments (for 'create')", - "items": { - "properties": { - "body": { - "type": "string" - }, - "new_line_num": { - "description": "new-file line (additions)", - "type": "number" - }, - "old_line_num": { - "description": "old-file line (deletions)", - "type": "number" - }, - "path": { - "type": "string" - } - }, - "type": "object" - }, - "type": "array" - }, - "commit_id": { - "description": "for 'create'", - "type": "string" - }, - "message": { - "description": "dismissal reason", - "type": "string" - }, - "method": { - "enum": [ - "create", - "submit", - "delete", - "dismiss", - "reply_comment", - "resolve_thread", - "unresolve_thread" - ], - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "pull_number": { - "description": "required except for 'resolve_thread'/'unresolve_thread'", - "type": "number" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "review_id": { - "description": "for 'submit'/'delete'/'dismiss'", - "type": "number" - }, - "state": { - "enum": [ - "APPROVED", - "REQUEST_CHANGES", - "COMMENT", - "PENDING" - ], - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Write pull request reviews", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "pull_request", - "access": "write", - "name": "pull_request_write", - "description": "Write pull requests: create, update, close, reopen, merge, update branch from base, manage reviewers.", - "inputSchema": { - "properties": { - "allow_maintainer_edit": { - "description": "for 'update'", - "type": "boolean" - }, - "assignee": { - "description": "for 'update'", - "type": "string" - }, - "assignees": { - "description": "for 'update'", - "items": { - "type": "string" - }, - "type": "array" - }, - "base": { - "description": "base branch (required for 'create')", - "type": "string" - }, - "body": { - "description": "required for 'create'; optional for 'update'", - "type": "string" - }, - "deadline": { - "description": "ISO 8601", - "type": "string" - }, - "delete_branch": { - "description": "for 'merge'", - "type": "boolean" - }, - "draft": { - "description": "uses 'WIP: ' title prefix", - "type": "boolean" - }, - "force_merge": { - "description": "merge even if checks fail", - "type": "boolean" - }, - "head": { - "description": "head branch (required for 'create')", - "type": "string" - }, - "head_commit_id": { - "description": "expected head SHA for conflict detection", - "type": "string" - }, - "labels": { - "description": "label IDs", - "items": { - "type": "number" - }, - "type": "array" - }, - "merge_style": { - "default": "merge", - "description": "for 'merge'", - "enum": [ - "merge", - "rebase", - "rebase-merge", - "squash", - "fast-forward-only" - ], - "type": "string" - }, - "merge_when_checks_succeed": { - "description": "for 'merge'", - "type": "boolean" - }, - "message": { - "description": "merge commit message or dismissal reason", - "type": "string" - }, - "method": { - "enum": [ - "create", - "update", - "close", - "reopen", - "merge", - "update_branch", - "add_reviewers", - "remove_reviewers" - ], - "type": "string" - }, - "milestone": { - "description": "for 'update'", - "type": "number" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "pull_number": { - "description": "required except for 'create'", - "type": "number" - }, - "remove_deadline": { - "description": "for 'update'", - "type": "boolean" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "reviewers": { - "description": "for 'add_reviewers'/'remove_reviewers'", - "items": { - "type": "string" - }, - "type": "array" - }, - "state": { - "description": "for 'update'", - "enum": [ - "open", - "closed" - ], - "type": "string" - }, - "team_reviewers": { - "description": "for 'add_reviewers'/'remove_reviewers'", - "items": { - "type": "string" - }, - "type": "array" - }, - "title": { - "description": "required for 'create'; optional for 'update'/'merge'", - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Create, update, close, reopen, or merge pull requests", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "release", - "access": "read", - "name": "get_latest_release", - "description": "Get the most recent published (non-draft) release in a repository.", - "inputSchema": { - "properties": { - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Get latest release", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "release", - "access": "read", - "name": "get_release", - "description": "Get a release by ID", - "inputSchema": { - "properties": { - "id": { - "type": "number" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "id" - ], - "type": "object" - }, - "annotations": { - "title": "Get release details", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "release", - "access": "read", - "name": "list_releases", - "description": "List releases in a repository, optionally filtered to drafts or pre-releases.", - "inputSchema": { - "properties": { - "is_draft": { - "type": "boolean" - }, - "is_pre_release": { - "type": "boolean" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "minimum": 1, - "type": "number" - }, - "per_page": { - "default": 20, - "description": "results per page", - "minimum": 1, - "type": "number" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "List releases", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "release", - "access": "write", - "name": "create_release", - "description": "Create a new release in a repository from a tag, optionally marking it as a draft or pre-release.", - "inputSchema": { - "properties": { - "body": { - "type": "string" - }, - "is_draft": { - "type": "boolean" - }, - "is_pre_release": { - "type": "boolean" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "tag_name": { - "type": "string" - }, - "target": { - "description": "commitish", - "type": "string" - }, - "title": { - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "tag_name", - "target", - "title" - ], - "type": "object" - }, - "annotations": { - "title": "Create a release", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "release", - "access": "write", - "name": "delete_release", - "description": "Delete a release from a repository by its numeric ID. This action is destructive and cannot be undone.", - "inputSchema": { - "properties": { - "id": { - "type": "number" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "id" - ], - "type": "object" - }, - "annotations": { - "title": "Delete a release", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "repository", - "access": "read", - "name": "get_repository_tree", - "description": "Get the file tree of a repository at a given ref (SHA, branch, or tag), optionally recursively.", - "inputSchema": { - "properties": { - "owner": { - "description": "repo owner", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "recursive": { - "type": "boolean" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "tree_sha": { - "description": "SHA, branch, or tag", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "tree_sha" - ], - "type": "object" - }, - "annotations": { - "title": "Get repository file tree", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "repository", - "access": "read", - "name": "list_my_repos", - "description": "List repositories owned by the authenticated user.", - "inputSchema": { - "properties": { - "page": { - "default": 1, - "description": "page", - "minimum": 1, - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "minimum": 1, - "type": "number" - } - }, - "required": [], - "type": "object" - }, - "annotations": { - "title": "List my repositories", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "repository", - "access": "read", - "name": "list_org_repos", - "description": "List repositories belonging to an organization.", - "inputSchema": { - "properties": { - "org": { - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "minimum": 1, - "type": "number" - }, - "per_page": { - "default": 100, - "description": "results per page", - "minimum": 1, - "type": "number" - } - }, - "required": [ - "org" - ], - "type": "object" - }, - "annotations": { - "title": "List organization repositories", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "repository", - "access": "write", - "name": "create_repo", - "description": "Create a new Git repository, optionally under an organization (defaults to the authenticated user's account), with options for visibility, template, license, .gitignore, and initial README.", - "inputSchema": { - "properties": { - "auto_init": { - "type": "boolean" - }, - "default_branch": { - "type": "string" - }, - "description": { - "type": "string" - }, - "gitignores": { - "type": "string" - }, - "issue_labels": { - "type": "string" - }, - "license": { - "type": "string" - }, - "name": { - "type": "string" - }, - "object_format_name": { - "enum": [ - "sha1", - "sha256" - ], - "type": "string" - }, - "organization": { - "description": "defaults to personal account", - "type": "string" - }, - "private": { - "type": "boolean" - }, - "readme": { - "type": "string" - }, - "template": { - "type": "boolean" - }, - "trust_model": { - "enum": [ - "default", - "collaborator", - "committer", - "collaboratorcommitter" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "annotations": { - "title": "Create a new repository", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "repository", - "access": "write", - "name": "fork_repo", - "description": "Fork an existing repository into the authenticated user's account or a target organization, optionally under a new name.", - "inputSchema": { - "properties": { - "name": { - "description": "fork name", - "type": "string" - }, - "organization": { - "description": "target org", - "type": "string" - }, - "repo": { - "type": "string" - }, - "user": { - "description": "owner of source repo", - "type": "string" - } - }, - "required": [ - "user", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Fork a repository", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "search", - "access": "read", - "name": "search_issues", - "description": "Search issues and PRs across repositories", - "inputSchema": { - "properties": { - "labels": { - "description": "comma-separated", - "type": "string" - }, - "owner": { - "description": "filter by owner", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "query": { - "type": "string" - }, - "state": { - "enum": [ - "open", - "closed", - "all" - ], - "type": "string" - }, - "type": { - "enum": [ - "issues", - "pulls" - ], - "type": "string" - } - }, - "required": [ - "query" - ], - "type": "object" - }, - "annotations": { - "title": "Search issues", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "search", - "access": "read", - "name": "search_org_teams", - "description": "Search for teams within an organization by name, optionally including each team's description in the results.", - "inputSchema": { - "properties": { - "includeDescription": { - "type": "boolean" - }, - "org": { - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "query": { - "type": "string" - } - }, - "required": [ - "org", - "query" - ], - "type": "object" - }, - "annotations": { - "title": "Search organization teams", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "search", - "access": "read", - "name": "search_repos", - "description": "Search for repositories by keyword, with filters for topic/description matching, owner, visibility, archived status, and sort order.", - "inputSchema": { - "properties": { - "isArchived": { - "type": "boolean" - }, - "isPrivate": { - "type": "boolean" - }, - "keywordInDescription": { - "type": "boolean" - }, - "keywordIsTopic": { - "type": "boolean" - }, - "order": { - "type": "string" - }, - "ownerID": { - "type": "number" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "query": { - "type": "string" - }, - "sort": { - "type": "string" - } - }, - "required": [ - "query" - ], - "type": "object" - }, - "annotations": { - "title": "Search repositories", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "search", - "access": "read", - "name": "search_users", - "description": "Search for Gitea users by username or full name.", - "inputSchema": { - "properties": { - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "query": { - "type": "string" - } - }, - "required": [ - "query" - ], - "type": "object" - }, - "annotations": { - "title": "Search users", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "tag", - "access": "read", - "name": "get_tag", - "description": "Get details for a single tag in a repository by name.", - "inputSchema": { - "properties": { - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "tag_name": { - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "tag_name" - ], - "type": "object" - }, - "annotations": { - "title": "Get tag details", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "tag", - "access": "read", - "name": "list_tags", - "description": "List all tags in a repository, paginated.", - "inputSchema": { - "properties": { - "owner": { - "description": "repo owner", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "minimum": 1, - "type": "number" - }, - "per_page": { - "default": 20, - "description": "results per page", - "minimum": 1, - "type": "number" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "List tags", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "tag", - "access": "write", - "name": "create_tag", - "description": "Create a new Git tag in a repository at a target commit, branch, or existing tag, with an optional annotation message.", - "inputSchema": { - "properties": { - "message": { - "description": "tag message", - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "tag_name": { - "type": "string" - }, - "target": { - "description": "commitish", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "tag_name" - ], - "type": "object" - }, - "annotations": { - "title": "Create a tag", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "tag", - "access": "write", - "name": "delete_tag", - "description": "Permanently delete a tag from a repository. This action is destructive and cannot be undone.", - "inputSchema": { - "properties": { - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "tag_name": { - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "tag_name" - ], - "type": "object" - }, - "annotations": { - "title": "Delete a tag", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "timetracking", - "access": "read", - "name": "timetracking_read", - "description": "Read time tracking: issue times, repo times, active stopwatches, your tracked times.", - "inputSchema": { - "properties": { - "issue_number": { - "description": "for 'list_issue_times'", - "type": "number" - }, - "method": { - "enum": [ - "list_issue_times", - "list_repo_times", - "get_my_stopwatches", - "get_my_times" - ], - "type": "string" - }, - "owner": { - "description": "for list_* methods", - "type": "string" - }, - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - }, - "repo": { - "description": "for list_* methods", - "type": "string" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "annotations": { - "title": "Read tracked time", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "timetracking", - "access": "write", - "name": "timetracking_write", - "description": "Write time tracking: stopwatches and entries.", - "inputSchema": { - "properties": { - "id": { - "description": "entry ID (for 'delete_time')", - "type": "number" - }, - "issue_number": { - "type": "number" - }, - "method": { - "enum": [ - "start_stopwatch", - "stop_stopwatch", - "delete_stopwatch", - "add_time", - "delete_time" - ], - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "time": { - "description": "seconds (for 'add_time')", - "type": "number" - } - }, - "required": [ - "method" - ], - "type": "object" - }, - "annotations": { - "title": "Add or manage tracked time", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "user", - "access": "read", - "name": "get_me", - "description": "Get current user", - "inputSchema": { - "properties": {}, - "required": [], - "type": "object" - }, - "annotations": { - "title": "Get current user information", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "user", - "access": "read", - "name": "get_user_orgs", - "description": "List current user's organizations", - "inputSchema": { - "properties": { - "page": { - "default": 1, - "description": "page", - "type": "number" - }, - "per_page": { - "default": 30, - "description": "results per page", - "type": "number" - } - }, - "required": [], - "type": "object" - }, - "annotations": { - "title": "Get user organizations", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "version", - "access": "read", - "name": "get_gitea_mcp_server_version", - "description": "Get the running version of the Gitea MCP Server itself (not the Gitea instance it connects to).", - "inputSchema": { - "properties": {}, - "required": [], - "type": "object" - }, - "annotations": { - "title": "Get server version", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "wiki", - "access": "read", - "name": "wiki_read", - "description": "Read wiki: list pages, get content, revision history.", - "inputSchema": { - "properties": { - "method": { - "enum": [ - "list", - "get", - "get_revisions" - ], - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "pageName": { - "description": "for 'get'/'get_revisions'", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Read wiki pages", - "readOnlyHint": true, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "scope": "wiki", - "access": "write", - "name": "wiki_write", - "description": "Write wiki pages: create, update, delete.", - "inputSchema": { - "properties": { - "content": { - "description": "for 'create'/'update'", - "type": "string" - }, - "message": { - "description": "commit message", - "type": "string" - }, - "method": { - "enum": [ - "create", - "update", - "delete" - ], - "type": "string" - }, - "owner": { - "description": "repo owner", - "type": "string" - }, - "pageName": { - "description": "for 'update'/'delete'", - "type": "string" - }, - "repo": { - "description": "repo name", - "type": "string" - }, - "title": { - "description": "for 'create'", - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" - }, - "annotations": { - "title": "Create, update, or delete wiki pages", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - } -] diff --git a/operation/tool_contract_test.go b/operation/tool_contract_test.go index 8af878d..d477905 100644 --- a/operation/tool_contract_test.go +++ b/operation/tool_contract_test.go @@ -1,179 +1,142 @@ package operation import ( - "bytes" "encoding/json" - "os" - "path/filepath" - "sort" + "slices" "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" ) -const updateToolContractEnv = "UPDATE_TOOL_CONTRACT" - -type toolContract struct { - Scope string `json:"scope"` - Access string `json:"access"` - Name string `json:"name"` - Description string `json:"description"` - InputSchema any `json:"inputSchema"` - Annotations contractAnnotations `json:"annotations"` -} - -type contractAnnotations struct { - Title string `json:"title"` - ReadOnlyHint bool `json:"readOnlyHint"` - DestructiveHint bool `json:"destructiveHint"` - IdempotentHint bool `json:"idempotentHint"` - OpenWorldHint bool `json:"openWorldHint"` -} - +// TestToolContract checks the properties every exposed tool must hold, rather +// than a snapshot of the current surface, so adding a tool needs no fixture +// update and a malformed schema fails here instead of panicking in AddTool. func TestToolContract(t *testing.T) { - const ( - wantDomains = 18 - wantTools = 54 - wantRead = 33 - wantWrite = 21 - ) - - contracts := make([]toolContract, 0, wantTools) - seenScopes := make(map[string]struct{}, wantDomains) - seenNames := make(map[string]struct{}, wantTools) - readCount, writeCount := 0, 0 - + scopeByName := map[string]string{} + seenScopes := map[string]struct{}{} for _, domain := range domainTools { scope := domain.Scope() if scope == "" { - t.Fatal("registered tool domain has an empty scope") + t.Error("domainTools contains a domain with an empty scope") } + // Tools() filters one domain by exactly one scope name, so a shared + // scope would make --scope select more than the caller asked for. if _, duplicate := seenScopes[scope]; duplicate { - t.Fatalf("duplicate tool domain scope %q", scope) + t.Errorf("domainTools contains a duplicate scope %q", scope) } seenScopes[scope] = struct{}{} for _, registered := range domain.ReadTools() { - contracts = append(contracts, decodeToolContract(t, scope, "read", registered.Tool)) - readCount++ + assertToolContract(t, scope, registered.Tool, true, scopeByName) } for _, registered := range domain.WriteTools() { - contracts = append(contracts, decodeToolContract(t, scope, "write", registered.Tool)) - writeCount++ + assertToolContract(t, scope, registered.Tool, false, scopeByName) } } + if len(scopeByName) == 0 { + t.Fatal("no tools are registered") + } +} - if len(seenScopes) != wantDomains { - t.Errorf("domain count = %d, want %d", len(seenScopes), wantDomains) - } - if len(contracts) != wantTools { - t.Errorf("tool count = %d, want %d", len(contracts), wantTools) - } - if readCount != wantRead { - t.Errorf("read tool count = %d, want %d", readCount, wantRead) - } - if writeCount != wantWrite { - t.Errorf("write tool count = %d, want %d", writeCount, wantWrite) - } +func assertToolContract(t *testing.T, scope string, definition *mcp.Tool, readOnly bool, scopeByName map[string]string) { + t.Helper() - for _, contract := range contracts { - if _, duplicate := seenNames[contract.Name]; duplicate { - t.Errorf("duplicate tool name %q", contract.Name) + t.Run(definition.Name, func(t *testing.T) { + if previous, duplicate := scopeByName[definition.Name]; duplicate { + t.Errorf("tool name is already registered in scope %q; AddTool would silently replace it", previous) } - seenNames[contract.Name] = struct{}{} - } + scopeByName[definition.Name] = scope - sort.Slice(contracts, func(i, j int) bool { - if contracts[i].Scope != contracts[j].Scope { - return contracts[i].Scope < contracts[j].Scope + // Strict MCP clients reject a tools/list entry without a description. + if definition.Description == "" { + t.Error("tool has no description") } - if contracts[i].Access != contracts[j].Access { - return contracts[i].Access < contracts[j].Access + + // A write tool registered as read stays exposed under --read-only. + if definition.Annotations == nil || definition.Annotations.ReadOnlyHint != readOnly { + t.Errorf("annotations = %+v, want readOnlyHint %v", definition.Annotations, readOnly) + } + + schema := decodeJSON(t, definition.InputSchema) + if schema["type"] != "object" { + t.Fatalf("input schema type = %v, want object", schema["type"]) + } + properties, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("input schema properties = %T, want a JSON object", schema["properties"]) + } + + for name, raw := range properties { + property, ok := raw.(map[string]any) + if !ok { + t.Errorf("property %q = %T, want a JSON object", name, raw) + continue + } + assertPropertyContract(t, name, property) } - return contracts[i].Name < contracts[j].Name }) - - got, err := json.MarshalIndent(contracts, "", " ") - if err != nil { - t.Fatalf("marshal tool contract: %v", err) - } - got = append(got, '\n') - - goldenPath := filepath.Join("testdata", "tools.golden.json") - if os.Getenv(updateToolContractEnv) == "1" { - if err := os.WriteFile(goldenPath, got, 0o644); err != nil { - t.Fatalf("update tool contract: %v", err) - } - } - - want, err := os.ReadFile(goldenPath) - if err != nil { - t.Fatalf("read tool contract: %v", err) - } - if !bytes.Equal(got, want) { - t.Errorf("tool contract changed; inspect the semantic diff before running %s=1 go test -run '^TestToolContract$' ./operation/", updateToolContractEnv) - } } -func decodeToolContract(t *testing.T, scope, access string, toolDefinition any) toolContract { +func assertPropertyContract(t *testing.T, name string, property map[string]any) { t.Helper() - data, err := json.Marshal(toolDefinition) - if err != nil { - t.Fatalf("marshal %s tool in scope %q: %v", access, scope, err) - } - var definition map[string]any - if err := json.Unmarshal(data, &definition); err != nil { - t.Fatalf("decode %s tool in scope %q: %v", access, scope, err) - } - - name := requiredString(t, definition, "name", scope) - description := requiredString(t, definition, "description", name) - inputSchema, ok := definition["inputSchema"].(map[string]any) + propertyType, ok := property["type"].(string) if !ok { - t.Fatalf("tool %q has inputSchema of type %T, want JSON object", name, definition["inputSchema"]) + t.Errorf("property %q has no type", name) + return } - // An omitted required keyword and an empty array have the same JSON Schema meaning. - if _, ok := inputSchema["required"]; !ok { - inputSchema["required"] = []any{} - } - annotations, _ := definition["annotations"].(map[string]any) - // Normalize protocol defaults independently of SDK omitempty behavior. - return toolContract{ - Scope: scope, - Access: access, - Name: name, - Description: description, - InputSchema: inputSchema, - Annotations: contractAnnotations{ - Title: stringField(annotations, "title", ""), - ReadOnlyHint: boolField(annotations, "readOnlyHint", false), - DestructiveHint: boolField(annotations, "destructiveHint", true), - IdempotentHint: boolField(annotations, "idempotentHint", false), - OpenWorldHint: boolField(annotations, "openWorldHint", true), - }, + enum, hasEnum := property["enum"].([]any) + if _, declared := property["enum"]; declared && len(enum) == 0 { + t.Errorf("property %q has an empty enum", name) + } + + defaultValue, hasDefault := property["default"] + if !hasDefault { + return + } + if !matchesJSONType(defaultValue, propertyType) { + t.Errorf("property %q default %#v is not a %s", name, defaultValue, propertyType) + } + if hasEnum && !slices.Contains(enum, defaultValue) { + t.Errorf("property %q default %#v is not one of its enum values %#v", name, defaultValue, enum) } } -func requiredString(t *testing.T, object map[string]any, key, owner string) string { +func matchesJSONType(value any, propertyType string) bool { + switch propertyType { + case "string": + _, ok := value.(string) + return ok + case "number": + _, ok := value.(float64) + return ok + case "boolean": + _, ok := value.(bool) + return ok + case "array": + _, ok := value.([]any) + return ok + case "object": + _, ok := value.(map[string]any) + return ok + default: + return false + } +} + +// decodeJSON round-trips through JSON so the assertions see what an MCP client +// receives rather than the Go values behind it. +func decodeJSON(t *testing.T, value any) map[string]any { t.Helper() - value, ok := object[key].(string) - if !ok || value == "" { - t.Fatalf("%s has missing or empty %q", owner, key) + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal: %v", err) } - return value -} - -func stringField(object map[string]any, key, fallback string) string { - if value, ok := object[key].(string); ok { - return value + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("decode: %v", err) } - return fallback -} - -func boolField(object map[string]any, key string, fallback bool) bool { - if value, ok := object[key].(bool); ok { - return value - } - return fallback + return decoded } diff --git a/pkg/annotation/annotation_test.go b/pkg/annotation/annotation_test.go index 7364062..b45ab5f 100644 --- a/pkg/annotation/annotation_test.go +++ b/pkg/annotation/annotation_test.go @@ -1,20 +1,50 @@ package annotation -import "testing" +import ( + "encoding/json" + "maps" + "testing" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// The hints are what clients use to decide whether a tool needs confirmation, so +// assert the encoded form: an omitted readOnlyHint reads as false either way, but +// only the explicit form survives a client that checks for the key. func TestAnnotations(t *testing.T) { - readOnly := ReadOnly("Read") - if readOnly.Title != "Read" || !readOnly.ReadOnlyHint || readOnly.DestructiveHint != nil { - t.Errorf("ReadOnly() = %#v", readOnly) - } - - write := Write("Write") - if write.Title != "Write" || write.ReadOnlyHint || write.DestructiveHint != nil { - t.Errorf("Write() = %#v", write) - } - - destructive := Destructive("Delete") - if destructive.Title != "Delete" || destructive.ReadOnlyHint || destructive.DestructiveHint == nil || !*destructive.DestructiveHint { - t.Errorf("Destructive() = %#v", destructive) + for _, test := range []struct { + name string + annotations *mcp.ToolAnnotations + want map[string]any + }{ + { + name: "ReadOnly", + annotations: ReadOnly("Read"), + want: map[string]any{"title": "Read", "readOnlyHint": true, "idempotentHint": false}, + }, + { + name: "Write", + annotations: Write("Write"), + want: map[string]any{"title": "Write", "readOnlyHint": false, "idempotentHint": false}, + }, + { + name: "Destructive", + annotations: Destructive("Delete"), + want: map[string]any{"title": "Delete", "readOnlyHint": false, "idempotentHint": false, "destructiveHint": true}, + }, + } { + t.Run(test.name, func(t *testing.T) { + encoded, err := json.Marshal(test.annotations) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var got map[string]any + if err := json.Unmarshal(encoded, &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if !maps.Equal(got, test.want) { + t.Errorf("annotations = %s, want %v", encoded, test.want) + } + }) } } diff --git a/pkg/tool/definition.go b/pkg/tool/definition.go index 6a85deb..eaf5d5c 100644 --- a/pkg/tool/definition.go +++ b/pkg/tool/definition.go @@ -10,7 +10,7 @@ type Property struct { } // PropertyOption configures one property in a tool's input schema. -type PropertyOption func(map[string]any) +type PropertyOption func(*Property) // NewDefinition builds a tool definition without enabling SDK-side validation. func NewDefinition(name, description string, annotations *mcp.ToolAnnotations, properties ...Property) *mcp.Tool { @@ -40,71 +40,67 @@ func NewDefinition(name, description string, annotations *mcp.ToolAnnotations, p } func String(name string, options ...PropertyOption) Property { - return newProperty(name, "string", false, options...) + return newProperty(name, map[string]any{"type": "string"}, options...) } func Number(name string, options ...PropertyOption) Property { - return newProperty(name, "number", false, options...) + return newProperty(name, map[string]any{"type": "number"}, options...) } func Boolean(name string, options ...PropertyOption) Property { - return newProperty(name, "boolean", false, options...) + return newProperty(name, map[string]any{"type": "boolean"}, options...) } func Array(name string, options ...PropertyOption) Property { - return newProperty(name, "array", false, options...) + return newProperty(name, map[string]any{"type": "array"}, options...) } func Object(name string, options ...PropertyOption) Property { - return newProperty(name, "object", true, options...) + return newProperty(name, map[string]any{"type": "object", "properties": map[string]any{}}, options...) } -func newProperty(name, propertyType string, object bool, options ...PropertyOption) Property { - schema := map[string]any{"type": propertyType} - if object { - schema["properties"] = map[string]any{} - } +func newProperty(name string, schema map[string]any, options ...PropertyOption) Property { + property := Property{name: name, schema: schema} for _, option := range options { - option(schema) + option(&property) } - - required, _ := schema["required"].(bool) - delete(schema, "required") - return Property{name: name, schema: schema, required: required} + return property } +// Required marks the property as required on the parent schema. It is not a +// property-level keyword, so it never touches the emitted property schema. func Required() PropertyOption { - return func(schema map[string]any) { - schema["required"] = true + return func(property *Property) { + property.required = true } } func Description(description string) PropertyOption { - return func(schema map[string]any) { - schema["description"] = description + return func(property *Property) { + property.schema["description"] = description } } func Enum(values ...string) PropertyOption { - return func(schema map[string]any) { - schema["enum"] = values + return func(property *Property) { + property.schema["enum"] = values } } func Default(value any) PropertyOption { - return func(schema map[string]any) { - schema["default"] = value + return func(property *Property) { + property.schema["default"] = value } } func Minimum(value float64) PropertyOption { - return func(schema map[string]any) { - schema["minimum"] = value + return func(property *Property) { + property.schema["minimum"] = value } } func Items(schema any) PropertyOption { - return func(propertySchema map[string]any) { - propertySchema["items"] = schema + return func(property *Property) { + property.schema["items"] = schema } } diff --git a/pkg/tool/definition_test.go b/pkg/tool/definition_test.go index 8e9f013..fd35720 100644 --- a/pkg/tool/definition_test.go +++ b/pkg/tool/definition_test.go @@ -1,7 +1,6 @@ package tool import ( - "encoding/json" "reflect" "testing" @@ -58,14 +57,6 @@ func TestNewDefinition(t *testing.T) { if !reflect.DeepEqual(definition.InputSchema, want) { t.Errorf("InputSchema = %#v, want %#v", definition.InputSchema, want) } - - data, err := json.Marshal(definition) - if err != nil { - t.Fatalf("json.Marshal() error = %v", err) - } - if !json.Valid(data) { - t.Fatalf("json.Marshal() returned invalid JSON: %s", data) - } } func TestNewDefinitionWithoutRequiredProperties(t *testing.T) { diff --git a/pkg/tool/handler_test.go b/pkg/tool/handler_test.go index f0f8fe5..3da572f 100644 --- a/pkg/tool/handler_test.go +++ b/pkg/tool/handler_test.go @@ -10,19 +10,23 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +func callTool(handler Handler, arguments json.RawMessage) (*mcp.CallToolResult, error) { + serverTool := ServerTool{Tool: &mcp.Tool{Name: "example"}, Handler: handler} + return serverTool.MCPHandler()(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Arguments: arguments}, + }) +} + +func captureArguments(into *map[string]any) Handler { + return func(_ context.Context, arguments map[string]any) (*mcp.CallToolResult, error) { + *into = arguments + return &mcp.CallToolResult{}, nil + } +} + func TestMCPHandler(t *testing.T) { var got map[string]any - serverTool := ServerTool{ - Tool: &mcp.Tool{Name: "example"}, - Handler: func(_ context.Context, arguments map[string]any) (*mcp.CallToolResult, error) { - got = arguments - return &mcp.CallToolResult{}, nil - }, - } - - result, err := serverTool.MCPHandler()(context.Background(), &mcp.CallToolRequest{ - Params: &mcp.CallToolParamsRaw{Arguments: json.RawMessage(`{"count":2,"nested":{"enabled":true}}`)}, - }) + result, err := callTool(captureArguments(&got), json.RawMessage(`{"count":2,"nested":{"enabled":true}}`)) if err != nil { t.Fatalf("MCPHandler() error = %v", err) } @@ -36,18 +40,13 @@ func TestMCPHandler(t *testing.T) { func TestMCPHandlerRejectsInvalidArguments(t *testing.T) { called := false - serverTool := ServerTool{ - Tool: &mcp.Tool{Name: "example"}, - Handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { - called = true - return &mcp.CallToolResult{}, nil - }, + handler := func(context.Context, map[string]any) (*mcp.CallToolResult, error) { + called = true + return &mcp.CallToolResult{}, nil } - for _, arguments := range []json.RawMessage{json.RawMessage(`[]`), json.RawMessage(`null`), json.RawMessage(`{"broken"`)} { - _, err := serverTool.MCPHandler()(context.Background(), &mcp.CallToolRequest{ - Params: &mcp.CallToolParamsRaw{Arguments: arguments}, - }) + for _, arguments := range []json.RawMessage{json.RawMessage(`[]`), json.RawMessage(`"text"`), json.RawMessage(`{"broken"`)} { + _, err := callTool(handler, arguments) assertProtocolErrorCode(t, err, jsonrpc.CodeInvalidParams) } if called { @@ -55,37 +54,43 @@ func TestMCPHandlerRejectsInvalidArguments(t *testing.T) { } } +// Tools without parameters are callable with an omitted or null "arguments", +// which is what clients send and what mcp-go accepted before the SDK migration. +func TestMCPHandlerAcceptsAbsentArguments(t *testing.T) { + for _, arguments := range []json.RawMessage{nil, json.RawMessage(`null`)} { + var got map[string]any + if _, err := callTool(captureArguments(&got), arguments); err != nil { + t.Fatalf("MCPHandler() with arguments %s error = %v", arguments, err) + } + if got == nil || len(got) != 0 { + t.Errorf("arguments = %#v, want an empty map", got) + } + } +} + func TestMCPHandlerConvertsErrorsAndRecoversPanics(t *testing.T) { - t.Run("handler error", func(t *testing.T) { - serverTool := ServerTool{ - Tool: &mcp.Tool{Name: "example"}, - Handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { + for _, test := range []struct { + name string + handler Handler + }{ + { + name: "handler error", + handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { return nil, errors.New("failed") }, - } - _, err := serverTool.MCPHandler()(context.Background(), &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{}}) - assertProtocolErrorCode(t, err, jsonrpc.CodeInternalError) - }) - - t.Run("panic", func(t *testing.T) { - calls := 0 - serverTool := ServerTool{ - Tool: &mcp.Tool{Name: "example"}, - Handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { - calls++ - if calls == 1 { - panic("failed") - } - return &mcp.CallToolResult{}, nil + }, + { + name: "panic", + handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { + panic("failed") }, - } - handler := serverTool.MCPHandler() - _, err := handler(context.Background(), &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{}}) - assertProtocolErrorCode(t, err, jsonrpc.CodeInternalError) - if _, err := handler(context.Background(), &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{}}); err != nil { - t.Fatalf("second handler call after panic error = %v", err) - } - }) + }, + } { + t.Run(test.name, func(t *testing.T) { + _, err := callTool(test.handler, nil) + assertProtocolErrorCode(t, err, jsonrpc.CodeInternalError) + }) + } } func assertProtocolErrorCode(t *testing.T, err error, want int64) { diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go index ef3d094..bcd7284 100644 --- a/pkg/tool/tool.go +++ b/pkg/tool/tool.go @@ -1,7 +1,6 @@ package tool import ( - "bytes" "context" "encoding/json" "errors" @@ -89,30 +88,18 @@ func (t *Tool) Tools() []ServerTool { // MCPHandler adapts a project handler to the official SDK's low-level handler. func (s ServerTool) MCPHandler() mcp.ToolHandler { return func(ctx context.Context, req *mcp.CallToolRequest) (result *mcp.CallToolResult, err error) { - name := "" - if s.Tool != nil { - name = s.Tool.Name - } defer func() { if recovered := recover(); recovered != nil { - panicErr := fmt.Errorf("panic recovered in %s tool handler: %v", name, recovered) + panicErr := fmt.Errorf("panic recovered in %s tool handler: %v", s.Tool.Name, recovered) log.Errorf("%s", panicErr) - result = nil - err = &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: panicErr.Error()} + err = internalError(panicErr) } }() - if req == nil || req.Params == nil { - return nil, invalidParamsError("missing tool call parameters") - } - arguments, err := decodeArguments(req.Params.Arguments) if err != nil { return nil, err } - if s.Handler == nil { - return nil, internalError(fmt.Errorf("tool %q has no handler", name)) - } result, err = s.Handler(ctx, arguments) if err != nil { @@ -127,25 +114,21 @@ func (s ServerTool) MCPHandler() mcp.ToolHandler { } func decodeArguments(raw json.RawMessage) (map[string]any, error) { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 { + // An omitted and a null "arguments" both mean the tool was called without any. + if len(raw) == 0 || string(raw) == "null" { return map[string]any{}, nil } - if bytes.Equal(trimmed, []byte("null")) { - return nil, invalidParamsError("tool arguments must be an object") - } var arguments map[string]any - if err := json.Unmarshal(trimmed, &arguments); err != nil { - return nil, invalidParamsError(fmt.Sprintf("invalid tool arguments: %v", err)) + if err := json.Unmarshal(raw, &arguments); err != nil { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf("invalid tool arguments: %v", err), + } } return arguments, nil } -func invalidParamsError(message string) error { - return &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: message} -} - func internalError(err error) error { return &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: err.Error()} }