From d3abd4416e7a96bc822765863d66c5a6d88c615c Mon Sep 17 00:00:00 2001 From: Thomas Nilles Date: Sun, 20 Sep 2026 08:00:30 -0400 Subject: [PATCH] Fix MCP tools/list rejection: wrap list_repos slice in object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The go-sdk infers each tool's outputSchema from its handler result type, and MCP structured output must be type "object". list_repos returned []repos.State, yielding outputSchema.type "array", which Claude Desktop rejects at tools/list — taking the whole server down. Wrap the slice in listReposOutput{Repos: ...} so the schema is an object, update the round-trip test, and record the struct-result rule in AGENT.md §8.1. Co-Authored-By: Claude Opus 4.8 --- AGENT.md | 7 +++++++ internal/mcp/mcp.go | 11 +++++++++-- internal/mcp/mcp_test.go | 5 +++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/AGENT.md b/AGENT.md index efc8324..457e2d4 100644 --- a/AGENT.md +++ b/AGENT.md @@ -367,6 +367,13 @@ obeys the safety rules (§1.4). `get_pending_switch`, `list_prs`. - Act: `git_status/checkout/commit/push/pull/create_branch`, `create_pr`, `merge_and_cleanup_pr`, `set_active_project`, `ack_switch`. +- **A tool's result type must be a struct, never a bare slice/map/scalar.** The + go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP + structured output must be a JSON **object** (`type: "object"`). A handler that + returns `[]T` yields `outputSchema.type: "array"`, which Claude Desktop rejects + at `tools/list` — and one bad tool takes the whole server down. Wrap any + collection result in a named output struct (e.g. `list_repos` returns + `listReposOutput{ Repos []repos.State }`, not `[]repos.State`). Fixed 2026-09-20. - **Destructive tools carry the §1.4 contract into MCP:** they describe exactly what they will do and default to the safe variant. The confirmation is the human's — surfaced through Claude and/or the app UI — not something the tool diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index c021d25..66abe33 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -21,6 +21,13 @@ type getRepoInput struct { Path string `json:"path" jsonschema:"absolute filesystem path of the repository, exactly as returned by list_repos"` } +// listReposOutput wraps the repository list. MCP structured output must be a JSON +// object, so the SDK-inferred outputSchema has to be type "object" — returning a +// bare slice yields type "array", which Claude Desktop rejects at tools/list. +type listReposOutput struct { + Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"` +} + // NewServer builds the MCP server and registers the (currently read-only) tools. func NewServer(svc *service.Service, version string) *mcpsdk.Server { s := mcpsdk.NewServer(&mcpsdk.Implementation{ @@ -34,8 +41,8 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server { mcpsdk.AddTool(s, &mcpsdk.Tool{ Name: "list_repos", Description: "List every Git repository GitManager has discovered, each with its current branch, dirty/clean state, ahead/behind counts, and remote names.", - }, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, []repos.State, error) { - return nil, svc.ListRepos(), nil + }, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, listReposOutput, error) { + return nil, listReposOutput{Repos: svc.ListRepos()}, nil }) // get_repo — details for one already-discovered repository. diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 0970f35..17d0794 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -65,8 +65,9 @@ func TestMCPRoundTrip(t *testing.T) { if err != nil { t.Fatalf("list_repos: %v", err) } - var states []repos.State - decodeResult(t, res, &states) + var listOut listReposOutput + decodeResult(t, res, &listOut) + states := listOut.Repos if len(states) != 1 { t.Fatalf("expected 1 repo, got %d: %+v", len(states), states) }