Fix MCP tools/list rejection: wrap list_repos slice in object

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 08:00:30 -04:00
parent e3336efc25
commit d3abd4416e
3 changed files with 19 additions and 4 deletions
+7
View File
@@ -367,6 +367,13 @@ obeys the safety rules (§1.4).
`get_pending_switch`, `list_prs`. `get_pending_switch`, `list_prs`.
- Act: `git_status/checkout/commit/push/pull/create_branch`, - Act: `git_status/checkout/commit/push/pull/create_branch`,
`create_pr`, `merge_and_cleanup_pr`, `set_active_project`, `ack_switch`. `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 - **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 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 human's — surfaced through Claude and/or the app UI — not something the tool
+9 -2
View File
@@ -21,6 +21,13 @@ type getRepoInput struct {
Path string `json:"path" jsonschema:"absolute filesystem path of the repository, exactly as returned by list_repos"` 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. // NewServer builds the MCP server and registers the (currently read-only) tools.
func NewServer(svc *service.Service, version string) *mcpsdk.Server { func NewServer(svc *service.Service, version string) *mcpsdk.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{ s := mcpsdk.NewServer(&mcpsdk.Implementation{
@@ -34,8 +41,8 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
mcpsdk.AddTool(s, &mcpsdk.Tool{ mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "list_repos", 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.", 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) { }, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, listReposOutput, error) {
return nil, svc.ListRepos(), nil return nil, listReposOutput{Repos: svc.ListRepos()}, nil
}) })
// get_repo — details for one already-discovered repository. // get_repo — details for one already-discovered repository.
+3 -2
View File
@@ -65,8 +65,9 @@ func TestMCPRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("list_repos: %v", err) t.Fatalf("list_repos: %v", err)
} }
var states []repos.State var listOut listReposOutput
decodeResult(t, res, &states) decodeResult(t, res, &listOut)
states := listOut.Repos
if len(states) != 1 { if len(states) != 1 {
t.Fatalf("expected 1 repo, got %d: %+v", len(states), states) t.Fatalf("expected 1 repo, got %d: %+v", len(states), states)
} }