From 6d8a20ae3c390b88fdde363c01725ebdf32bac74 Mon Sep 17 00:00:00 2001 From: Nartov Ilya Date: Tue, 13 Oct 2026 12:07:57 +0300 Subject: [PATCH] mcp: omit structured output from error results A typed tool handler that reports a tool execution error by returning a result with IsError set (and a nil error) still has its Out value marshaled into structuredContent and validated against the output schema. Skip that step for error results, as is already done when the handler asks for input. Fixes #1387 --- mcp/server.go | 4 +++- mcp/tool_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/mcp/server.go b/mcp/server.go index a6bbdea7..c1ded2a2 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -443,8 +443,10 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa // Marshal the output and put the RawMessage in the StructuredContent field. // Skip when the handler asked for input (multi round-trip): content and // an input-required result are mutually exclusive on the wire. + // Skip for an error result too: out is not a tool result then, and + // its zero value may not satisfy the output schema. var outval any = out - if asksForInput(res) { + if asksForInput(res) || res.IsError { outval = nil } else if elemZero != nil { // Avoid typed nil, which will serialize as JSON null. diff --git a/mcp/tool_test.go b/mcp/tool_test.go index b47b6505..1e779f40 100644 --- a/mcp/tool_test.go +++ b/mcp/tool_test.go @@ -225,6 +225,53 @@ func TestToolErrorHandling(t *testing.T) { }) } +func TestToolErrorResultOmitsOutput(t *testing.T) { + type out struct { + Status string `json:"status"` + } + server := NewServer(testImpl, nil) + AddTool(server, &Tool{ + Name: "failing", + OutputSchema: &jsonschema.Schema{ + Type: "object", + Required: []string{"status"}, + Properties: map[string]*jsonschema.Schema{"status": {Type: "string", Enum: []any{"ok"}}}, + }, + }, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, out, error) { + return &CallToolResult{ + IsError: true, + Content: []Content{&TextContent{Text: "no such item"}}, + }, out{}, nil + }) + + ct, st := NewInMemoryTransports() + if _, err := server.Connect(context.Background(), st, nil); err != nil { + t.Fatal(err) + } + cs, err := NewClient(testImpl, nil).Connect(context.Background(), ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(context.Background(), &CallToolParams{Name: "failing", Arguments: map[string]any{}}) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if !res.IsError { + t.Error("IsError = false, want true") + } + if res.StructuredContent != nil { + t.Errorf("StructuredContent = %v, want nil", res.StructuredContent) + } + if len(res.Content) != 1 { + t.Fatalf("got %d content blocks, want 1", len(res.Content)) + } + if tc, ok := res.Content[0].(*TextContent); !ok || tc.Text != "no such item" { + t.Errorf("Content[0] = %v, want the handler's error text", res.Content[0]) + } +} + func TestValidateToolName(t *testing.T) { t.Run("valid", func(t *testing.T) { validTests := []struct {