Skip to content

Commit 79e1a13

Browse files
mcp: tool.InputSchema and tool.OutputSchema validation (SEP-2106) (#1009)
Fixes #990
1 parent 91c010f commit 79e1a13

8 files changed

Lines changed: 377 additions & 40 deletions

File tree

docs/server.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -312,9 +312,13 @@ mcp.AddTool(server, &mcp.Tool{Name: "my_tool"}, handler)
312312

313313
This does the following automatically:
314314

315-
- If `Tool.InputSchema` or `Tool.OutputSchema` are unset, the input and output
316-
schemas are inferred from the `In` type, which must be a struct or map.
317-
Optional `jsonschema` struct tags provide argument descriptions.
315+
- If `Tool.InputSchema` is unset, the input schema is inferred from the `In`
316+
type, which must be a struct or map.
317+
- If `Tool.OutputSchema` is unset and the `Out` type is not `any`, the output
318+
schema is inferred from the `Out` type. Per SEP-2106, `Out` may be any Go
319+
type whose inferred schema is a valid JSON Schema (struct, map, slice,
320+
primitive, etc.).
321+
- Optional `jsonschema` struct tags provide argument and output descriptions.
318322
- Tool arguments are validated against the input schema.
319323
- Tool arguments are marshaled into the `In` value.
320324
- Tool output (the `Out` value) is marshaled into the result's

internal/docs/server.src.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,13 @@ mcp.AddTool(server, &mcp.Tool{Name: "my_tool"}, handler)
165165

166166
This does the following automatically:
167167

168-
- If `Tool.InputSchema` or `Tool.OutputSchema` are unset, the input and output
169-
schemas are inferred from the `In` type, which must be a struct or map.
170-
Optional `jsonschema` struct tags provide argument descriptions.
168+
- If `Tool.InputSchema` is unset, the input schema is inferred from the `In`
169+
type, which must be a struct or map.
170+
- If `Tool.OutputSchema` is unset and the `Out` type is not `any`, the output
171+
schema is inferred from the `Out` type. Per SEP-2106, `Out` may be any Go
172+
type whose inferred schema is a valid JSON Schema (struct, map, slice,
173+
primitive, etc.).
174+
- Optional `jsonschema` struct tags provide argument and output descriptions.
171175
- Tool arguments are validated against the input schema.
172176
- Tool arguments are marshaled into the `In` value.
173177
- Tool output (the `Out` value) is marshaled into the result's

mcp/content.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,9 @@ type ToolResultContent struct {
234234
ToolUseID string
235235
// Content holds the unstructured result of the tool call.
236236
Content []Content
237-
// StructuredContent holds an optional structured result as a JSON object.
237+
// StructuredContent holds an optional structured result. Per SEP-2106, it
238+
// may be any valid JSON value (object, array, or primitive) conforming to
239+
// the tool's output schema.
238240
StructuredContent any
239241
// IsError indicates whether the tool call ended in an error.
240242
IsError bool

mcp/protocol.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,9 @@ type CallToolResult struct {
270270
Content []Content `json:"content"`
271271

272272
// StructuredContent is an optional value that represents the structured
273-
// result of the tool call. It must marshal to a JSON object.
273+
// result of the tool call. Per SEP-2106, it may marshal to any valid JSON
274+
// value (object, array, or primitive) conforming to the tool's
275+
// [Tool.OutputSchema].
274276
//
275277
// When using a [ToolHandlerFor] with structured output, you should not
276278
// populate this field. It will be automatically populated with the typed Out

mcp/server.go

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,8 @@ func (s *Server) RemovePrompts(names ...string) {
232232
// that takes no input, or one where any input is valid, set [Tool.InputSchema] to
233233
// `{"type": "object"}`, using your preferred library or `json.RawMessage`.
234234
//
235-
// If present, [Tool.OutputSchema] must also have type "object".
235+
// If present, [Tool.OutputSchema] may be any valid JSON Schema (object, array,
236+
// primitive, or composition).
236237
//
237238
// When the handler is invoked as part of a CallTool request, req.Params.Arguments
238239
// will be a json.RawMessage.
@@ -279,16 +280,10 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) {
279280
if s == nil {
280281
panic(fmt.Errorf("AddTool %q: output schema is nil", t.Name))
281282
}
282-
if s.Type != "object" {
283-
panic(fmt.Errorf(`AddTool %q: output schema must have type "object"`, t.Name))
284-
}
285283
} else {
286-
var m map[string]any
284+
var m any
287285
if err := remarshal(t.OutputSchema, &m); err != nil {
288-
panic(fmt.Errorf("AddTool %q: can't marshal output schema to a JSON object: %v", t.Name, err))
289-
}
290-
if typ := m["type"]; typ != "object" {
291-
panic(fmt.Errorf(`AddTool %q: output schema must have type "object" (got %v)`, t.Name, typ))
286+
panic(fmt.Errorf("AddTool %q: can't marshal output schema to JSON: %v", t.Name, err))
292287
}
293288
}
294289
}
@@ -340,7 +335,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa
340335
}
341336
// Validate input and apply defaults.
342337
var err error
343-
input, err = applySchema(input, inputResolved)
338+
input, err = applySchema(input, inputResolved, false)
344339
if err != nil {
345340
var errRes CallToolResult
346341
errRes.SetError(fmt.Errorf("validating \"arguments\": %v", err))
@@ -402,7 +397,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa
402397
//
403398
// We validate against the JSON, rather than the output value, as
404399
// some types may have custom JSON marshalling (issue #447).
405-
outJSON, err = applySchema(outJSON, outputResolved)
400+
outJSON, err = applySchema(outJSON, outputResolved, true)
406401
if err != nil {
407402
return nil, fmt.Errorf("validating tool output: %w", err)
408403
}
@@ -411,10 +406,18 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa
411406
// If the Content field isn't being used, return the serialized JSON in a
412407
// TextContent block, as the spec suggests:
413408
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content.
409+
//
410+
// Ensure a serialized-JSON TextContent fallback is present in case of servers using array
411+
// or primitive structuredContent, so that pre-SEP-2106 clients can recover the structured
412+
// payload from unstructured content.
414413
if res.Content == nil {
415414
res.Content = []Content{&TextContent{
416415
Text: string(outJSON),
417416
}}
417+
} else if !isObjectJSON(outJSON) {
418+
res.Content = append(res.Content, &TextContent{
419+
Text: string(outJSON),
420+
})
418421
}
419422
}
420423
return res, nil
@@ -525,9 +528,10 @@ func setSchema[T any](sfield *any, rfield **jsonschema.Resolved, cache *SchemaCa
525528
// empty object schema value.
526529
//
527530
// If the tool's output schema is nil, and the Out type is not 'any', the
528-
// output schema is set to the schema inferred from the Out type argument,
529-
// which must also be a map or struct. If the Out type is 'any', the output
530-
// schema is omitted.
531+
// output schema is set to the schema inferred from the Out type argument.
532+
// Per SEP-2106, the Out type may be any Go type whose inferred schema is a
533+
// valid JSON Schema (struct, map, slice, primitive, etc.). If the Out type is
534+
// 'any', the output schema is omitted.
531535
//
532536
// Unlike [Server.AddTool], AddTool does a lot automatically, and forces
533537
// tools to conform to the MCP spec. See [ToolHandlerFor] for a detailed

mcp/server_test.go

Lines changed: 194 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,8 @@ func panics(f func()) (b bool) {
521521
}
522522

523523
func TestAddTool(t *testing.T) {
524-
// AddTool should panic if In or Out are not JSON objects.
524+
// AddTool should panic if In is not a JSON object.
525+
// Out may be of any type whose inferred JSON Schema is valid.
525526
s := NewServer(testImpl, nil)
526527
if !panics(func() {
527528
AddTool(s, &Tool{Name: "T1"}, func(context.Context, *CallToolRequest, string) (*CallToolResult, any, error) { return nil, nil, nil })
@@ -535,12 +536,12 @@ func TestAddTool(t *testing.T) {
535536
}) {
536537
t.Error("good In: expected no panic")
537538
}
538-
if !panics(func() {
539-
AddTool(s, &Tool{Name: "T2"}, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, int, error) {
539+
if panics(func() {
540+
AddTool(s, &Tool{Name: "T3"}, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, int, error) {
540541
return nil, 0, nil
541542
})
542543
}) {
543-
t.Error("bad Out: expected panic")
544+
t.Error("primitive Out: expected no panic")
544545
}
545546
}
546547

@@ -653,6 +654,195 @@ func TestAddToolNilSchema(t *testing.T) {
653654
}
654655
}
655656

657+
// TestAddToolNonObjectOutputSchema verifies the low-level
658+
// Server.AddTool accepts an output schema whose root type is not "object"
659+
// (array or primitive) and structured content of the matching shape
660+
// round-trips end-to-end.
661+
func TestAddToolNonObjectOutputSchema(t *testing.T) {
662+
ctx := context.Background()
663+
664+
for _, tc := range []struct {
665+
name string
666+
outputSchema any
667+
content any
668+
want any
669+
}{
670+
{
671+
name: "array of objects",
672+
outputSchema: &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "object"}},
673+
content: []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}},
674+
want: []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}},
675+
},
676+
{
677+
name: "primitive number (map-based schema)",
678+
outputSchema: map[string]any{"type": "number"},
679+
content: 42.0,
680+
want: 42.0,
681+
},
682+
{
683+
name: "primitive string (RawMessage schema)",
684+
outputSchema: json.RawMessage(`{"type":"string"}`),
685+
content: "hello",
686+
want: "hello",
687+
},
688+
} {
689+
t.Run(tc.name, func(t *testing.T) {
690+
server := NewServer(testImpl, nil)
691+
handler := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
692+
return &CallToolResult{StructuredContent: tc.content}, nil
693+
}
694+
tool := &Tool{
695+
Name: "t",
696+
InputSchema: &jsonschema.Schema{Type: "object"},
697+
OutputSchema: tc.outputSchema,
698+
}
699+
// Must not panic.
700+
server.AddTool(tool, handler)
701+
702+
ct, st := NewInMemoryTransports()
703+
if _, err := server.Connect(ctx, st, nil); err != nil {
704+
t.Fatal(err)
705+
}
706+
client := NewClient(testImpl, nil)
707+
cs, err := client.Connect(ctx, ct, nil)
708+
if err != nil {
709+
t.Fatal(err)
710+
}
711+
defer cs.Close()
712+
713+
res, err := cs.CallTool(ctx, &CallToolParams{Name: "t"})
714+
if err != nil {
715+
t.Fatal(err)
716+
}
717+
if res.IsError {
718+
t.Fatalf("unexpected tool error: %v", res.Content)
719+
}
720+
if diff := cmp.Diff(tc.want, res.StructuredContent); diff != "" {
721+
t.Errorf("structured content mismatch (-want +got):\n%s", diff)
722+
}
723+
})
724+
}
725+
}
726+
727+
// TestAddToolGenericNonObjectOutput verifies SEP-2106 for the generic
728+
// AddTool[In, Out] helper: Out may be a slice or primitive whose inferred
729+
// JSON Schema has a non-object root.
730+
func TestAddToolGenericNonObjectOutput(t *testing.T) {
731+
ctx := context.Background()
732+
733+
type item struct {
734+
ID string `json:"id"`
735+
}
736+
737+
t.Run("slice output", func(t *testing.T) {
738+
server := NewServer(testImpl, nil)
739+
AddTool(server, &Tool{Name: "list"},
740+
func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, []item, error) {
741+
return nil, []item{{ID: "u1"}, {ID: "u2"}}, nil
742+
})
743+
744+
ct, st := NewInMemoryTransports()
745+
if _, err := server.Connect(ctx, st, nil); err != nil {
746+
t.Fatal(err)
747+
}
748+
client := NewClient(testImpl, nil)
749+
cs, err := client.Connect(ctx, ct, nil)
750+
if err != nil {
751+
t.Fatal(err)
752+
}
753+
defer cs.Close()
754+
755+
// Verify tools/list reports an array-rooted output schema.
756+
// jsonschema-go infers ["null","array"] for slices since nil slices
757+
// are valid; accept either form.
758+
lt, err := cs.ListTools(ctx, nil)
759+
if err != nil {
760+
t.Fatal(err)
761+
}
762+
if len(lt.Tools) != 1 {
763+
t.Fatalf("got %d tools, want 1", len(lt.Tools))
764+
}
765+
gotSchema, _ := lt.Tools[0].OutputSchema.(map[string]any)
766+
typeOK := false
767+
switch typ := gotSchema["type"].(type) {
768+
case string:
769+
typeOK = typ == "array"
770+
case []any:
771+
for _, e := range typ {
772+
if e == "array" {
773+
typeOK = true
774+
}
775+
}
776+
}
777+
if !typeOK {
778+
t.Errorf("output schema type = %v, want to include %q", gotSchema["type"], "array")
779+
}
780+
781+
res, err := cs.CallTool(ctx, &CallToolParams{Name: "list"})
782+
if err != nil {
783+
t.Fatal(err)
784+
}
785+
if res.IsError {
786+
t.Fatalf("unexpected tool error: %v", res.Content)
787+
}
788+
want := []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}}
789+
if diff := cmp.Diff(want, res.StructuredContent); diff != "" {
790+
t.Errorf("structured content mismatch (-want +got):\n%s", diff)
791+
}
792+
})
793+
794+
t.Run("primitive output", func(t *testing.T) {
795+
server := NewServer(testImpl, nil)
796+
AddTool(server, &Tool{Name: "count"},
797+
func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, int, error) {
798+
return nil, 42, nil
799+
})
800+
801+
ct, st := NewInMemoryTransports()
802+
if _, err := server.Connect(ctx, st, nil); err != nil {
803+
t.Fatal(err)
804+
}
805+
client := NewClient(testImpl, nil)
806+
cs, err := client.Connect(ctx, ct, nil)
807+
if err != nil {
808+
t.Fatal(err)
809+
}
810+
defer cs.Close()
811+
812+
res, err := cs.CallTool(ctx, &CallToolParams{Name: "count"})
813+
if err != nil {
814+
t.Fatal(err)
815+
}
816+
if res.IsError {
817+
t.Fatalf("unexpected tool error: %v", res.Content)
818+
}
819+
if diff := cmp.Diff(float64(42), res.StructuredContent); diff != "" {
820+
t.Errorf("structured content mismatch (-want +got):\n%s", diff)
821+
}
822+
})
823+
}
824+
825+
// TestAddToolInputSchemaComposition verifies SEP-2106 (input side): composition
826+
// keywords such as oneOf are allowed on the input schema alongside
827+
// type:"object".
828+
func TestAddToolInputSchemaComposition(t *testing.T) {
829+
server := NewServer(testImpl, nil)
830+
tool := &Tool{
831+
Name: "lookup",
832+
InputSchema: &jsonschema.Schema{
833+
Type: "object",
834+
OneOf: []*jsonschema.Schema{
835+
{Required: []string{"id"}, Properties: map[string]*jsonschema.Schema{"id": {Type: "string"}}},
836+
{Required: []string{"name"}, Properties: map[string]*jsonschema.Schema{"name": {Type: "string"}}},
837+
},
838+
},
839+
}
840+
// Must not panic.
841+
server.AddTool(tool, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
842+
return &CallToolResult{}, nil
843+
})
844+
}
845+
656846
type schema = jsonschema.Schema
657847

658848
func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut any, wantErrContaining string) {

0 commit comments

Comments
 (0)