From 0a2f48969b8b0ee9f3184517eb24aadc518505a7 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Fri, 22 Aug 2025 07:08:25 -0400 Subject: [PATCH] mcp: enforce input schema type "object" The spec makes it clear that input schemas must have type "object". Enforce that. Allow "any" as an input argument type by special-casing it. Fixes #283. --- mcp/mcp_test.go | 10 +++++----- mcp/server.go | 9 +++++++++ mcp/server_test.go | 2 +- mcp/streamable_test.go | 6 +++--- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 9c578392..4e3ac443 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -97,7 +97,7 @@ func TestEndToEnd(t *testing.T) { Name: "greet", Description: "say hi", }, sayHi) - AddTool(s, &Tool{Name: "fail", InputSchema: &jsonschema.Schema{}}, + AddTool(s, &Tool{Name: "fail", InputSchema: &jsonschema.Schema{Type: "object"}}, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, any, error) { return nil, nil, errTestFailure }) @@ -247,7 +247,7 @@ func TestEndToEnd(t *testing.T) { t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff) } - s.AddTool(&Tool{Name: "T", InputSchema: &jsonschema.Schema{}}, nopHandler) + s.AddTool(&Tool{Name: "T", InputSchema: &jsonschema.Schema{Type: "object"}}, nopHandler) waitForNotification(t, "tools") s.RemoveTools("T") waitForNotification(t, "tools") @@ -674,7 +674,7 @@ func TestCancellation(t *testing.T) { return nil, nil, nil } cs, _ := basicConnection(t, func(s *Server) { - AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{}}, slowRequest) + AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowRequest) }) defer cs.Close() @@ -961,8 +961,8 @@ func TestAddTool_DuplicateNoPanicAndNoDuplicate(t *testing.T) { // Use two distinct Tool instances with the same name but different // descriptions to ensure the second replaces the first // This case was written specifically to reproduce a bug where duplicate tools where causing jsonschema errors - t1 := &Tool{Name: "dup", Description: "first", InputSchema: &jsonschema.Schema{}} - t2 := &Tool{Name: "dup", Description: "second", InputSchema: &jsonschema.Schema{}} + t1 := &Tool{Name: "dup", Description: "first", InputSchema: &jsonschema.Schema{Type: "object"}} + t2 := &Tool{Name: "dup", Description: "second", InputSchema: &jsonschema.Schema{Type: "object"}} s.AddTool(t1, nopHandler) s.AddTool(t2, nopHandler) }) diff --git a/mcp/server.go b/mcp/server.go index 740b2b9d..1e151df0 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -162,6 +162,9 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { // discovered until runtime, when the LLM sent bad data. panic(fmt.Errorf("AddTool %q: missing input schema", t.Name)) } + if t.InputSchema.Type != "object" { + panic(fmt.Errorf(`AddTool %q: input schema must have type "object"`, t.Name)) + } st := &serverTool{tool: t, handler: h} // Assume there was a change, since add replaces existing tools. // (It's possible a tool was replaced with an identical one, but not worth checking.) @@ -190,6 +193,12 @@ func ToolFor[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHandle // TODO(v0.3.0): test func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHandler, error) { tt := *t + + // Special handling for an "any" input: treat as an empty object. + if reflect.TypeFor[In]() == reflect.TypeFor[any]() && t.InputSchema == nil { + tt.InputSchema = &jsonschema.Schema{Type: "object"} + } + var inputResolved *jsonschema.Resolved if _, err := setSchema[In](&tt.InputSchema, &inputResolved); err != nil { return nil, nil, fmt.Errorf("input schema: %w", err) diff --git a/mcp/server_test.go b/mcp/server_test.go index 1ed4c3cc..81a59615 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -232,7 +232,7 @@ func TestServerPaginateVariousPageSizes(t *testing.T) { } func TestServerCapabilities(t *testing.T) { - tool := &Tool{Name: "t", InputSchema: &jsonschema.Schema{}} + tool := &Tool{Name: "t", InputSchema: &jsonschema.Schema{Type: "object"}} testCases := []struct { name string configureServer func(s *Server) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 603be473..c9b00ed8 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -219,7 +219,7 @@ func testClientReplay(t *testing.T, test clientReplayTest) { // proxy-killing action. serverReadyToKillProxy := make(chan struct{}) serverClosed := make(chan struct{}) - AddTool(server, &Tool{Name: "multiMessageTool", InputSchema: &jsonschema.Schema{}}, + AddTool(server, &Tool{Name: "multiMessageTool", InputSchema: &jsonschema.Schema{Type: "object"}}, func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) { // Send one message to the request context, and another to a background // context (which will end up on the hanging GET). @@ -353,7 +353,7 @@ func TestServerInitiatedSSE(t *testing.T) { t.Fatalf("client.Connect() failed: %v", err) } defer clientSession.Close() - AddTool(server, &Tool{Name: "testTool", InputSchema: &jsonschema.Schema{}}, + AddTool(server, &Tool{Name: "testTool", InputSchema: &jsonschema.Schema{Type: "object"}}, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, any, error) { return &CallToolResult{}, nil, nil }) @@ -658,7 +658,7 @@ func TestStreamableServerTransport(t *testing.T) { // behavior, if any. server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) server.AddTool( - &Tool{Name: "tool", InputSchema: &jsonschema.Schema{}}, + &Tool{Name: "tool", InputSchema: &jsonschema.Schema{Type: "object"}}, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { if test.tool != nil { test.tool(t, ctx, req.Session)