mcp: enforce input schema type "object" (#349)

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.
This commit is contained in:
Jonathan Amsterdam 2025-08-22 11:04:44 -04:00 • committed by GitHub
commit 18c96d6912
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 18 additions and 9 deletions

View file

@ -98,7 +98,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
})
@ -257,7 +257,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")
@ -697,7 +697,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()
@ -1496,8 +1496,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)
})

View file

@ -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)

View file

@ -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)

View file

@ -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)