diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 47563523..144d9ceb 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -28,6 +28,13 @@ Options listed below were added and will be removed in the 1.11.0 version of the that large integers and other exact JSON values do not silently lose precision. +- `schemaandmetafloat64` added. If set to `1`, numbers in tool input and + output schemas, elicitation schemas, and the `_meta` of tools, tool results, + their content, and elicitation requests are decoded as `float64`, restoring + the previous behavior. The default behavior decodes them as `json.Number`, as + for `CallToolResult.StructuredContent`, so that large integers and other + exact JSON values do not silently lose precision. + - `disablepromptargsvalidation` added. If set to `1`, `Server.getPrompt` will call the prompt handler even when a required argument of the prompt is missing from the request, restoring the previous behavior. The default diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 66f36ff2..db5e9112 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -27,6 +27,13 @@ Options listed below were added and will be removed in the 1.11.0 version of the that large integers and other exact JSON values do not silently lose precision. +- `schemaandmetafloat64` added. If set to `1`, numbers in tool input and + output schemas, elicitation schemas, and the `_meta` of tools, tool results, + their content, and elicitation requests are decoded as `float64`, restoring + the previous behavior. The default behavior decodes them as `json.Number`, as + for `CallToolResult.StructuredContent`, so that large integers and other + exact JSON values do not silently lose precision. + - `disablepromptargsvalidation` added. If set to `1`, `Server.getPrompt` will call the prompt handler even when a required argument of the prompt is missing from the request, restoring the previous behavior. The default diff --git a/mcp/protocol.go b/mcp/protocol.go index 8ca96b24..134e870c 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -333,6 +333,8 @@ type CallToolParamsRaw struct { type CallToolResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. + // Numbers received from the wire, here and in the metadata of Content, are + // represented as [json.Number] to preserve their exact values. Meta `json:"_meta,omitempty"` // A list of content objects that represent the unstructured result of the tool @@ -445,6 +447,22 @@ func (r *CallToolResult) NeedsInput() bool { return r.resultType == resultTypeIn // losing precision. The option will be removed in the 1.11.0 version of the SDK. var structuredcontentfloat64 = mcpgodebug.Value("structuredcontentfloat64") +// schemaandmetafloat64 is a compatibility parameter that restores the previous +// behavior of decoding numbers in tool and elicitation schemas, and in the +// metadata of tools, tool results and their content, and elicitation requests, +// as float64 values. By default, numbers are decoded as [json.Number] to avoid +// losing precision. The option will be removed in the 1.11.0 version of the SDK. +var schemaandmetafloat64 = mcpgodebug.Value("schemaandmetafloat64") + +// unmarshalSchemaAndMeta is [internaljson.UnmarshalUseNumber], or +// [internaljson.Unmarshal] if schemaandmetafloat64 is set. +func unmarshalSchemaAndMeta(data []byte, v any) error { + if schemaandmetafloat64 == "1" { + return internaljson.Unmarshal(data, v) + } + return internaljson.UnmarshalUseNumber(data, v) +} + func (x *CallToolResult) MarshalJSON() ([]byte, error) { type res CallToolResult // avoid recursion type wire struct { @@ -471,7 +489,7 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error { StructuredContent json.RawMessage `json:"structuredContent"` ResultType resultType `json:"resultType"` } - if err := internaljson.Unmarshal(data, &wire); err != nil { + if err := unmarshalSchemaAndMeta(data, &wire); err != nil { return err } if len(wire.StructuredContent) > 0 { @@ -2000,6 +2018,8 @@ func (x *SetLoggingLevelParams) SetProgressToken(t any) { setProgressToken(x, t) type Tool struct { // See [specification/2025-06-18/basic/index#general-fields] for notes on _meta // usage. + // Numbers received from the wire are represented as [json.Number] to + // preserve their exact values. Meta `json:"_meta,omitempty"` // Optional additional tool information. // @@ -2022,7 +2042,8 @@ type Tool struct { // [Server.AddTool]. // // From the client, this field will hold the default JSON marshaling of the - // server's input schema (a map[string]any). + // server's input schema (a map[string]any), with numbers represented as + // [json.Number] to preserve their exact values. InputSchema any `json:"inputSchema"` // Intended for programmatic or logical use, but used as a display name in past // specs or fallback (if title isn't present). @@ -2040,7 +2061,8 @@ type Tool struct { // [Server.AddTool]. // // From the client, this field will hold the default JSON marshaling of the - // server's output schema (a map[string]any). + // server's output schema (a map[string]any), with numbers represented as + // [json.Number] to preserve their exact values. OutputSchema any `json:"outputSchema,omitempty"` // Intended for UI and end-user contexts — optimized to be human-readable and // easily understood, even by those unfamiliar with domain-specific terminology. @@ -2051,6 +2073,11 @@ type Tool struct { Icons []Icon `json:"icons,omitempty"` } +func (t *Tool) UnmarshalJSON(data []byte) error { + type tool Tool // avoid recursion + return unmarshalSchemaAndMeta(data, (*tool)(t)) +} + // hintomitempty is a compatibility parameter that restores the pre-1.7.0 // behavior of [ToolAnnotations] JSON marshaling, where false-valued bare bool // fields (ReadOnlyHint, IdempotentHint) were omitted from the output. @@ -2228,6 +2255,8 @@ func (x *SubscriptionsListenResult) isNil() bool { return x == nil } type ElicitParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. + // Numbers received from the wire are represented as [json.Number] to + // preserve their exact values. Meta `json:"_meta,omitempty"` // The mode of elicitation to use. // @@ -2243,7 +2272,8 @@ type ElicitParams struct { // which only supports the 2020-12 draft of the JSON schema spec. // // From the client, this field will use the default JSON marshaling (a - // map[string]any). + // map[string]any), with numbers represented as [json.Number] to preserve + // their exact values. // // Only top-level properties are allowed, without nesting. // @@ -2259,6 +2289,11 @@ type ElicitParams struct { ElicitationID string `json:"elicitationId,omitempty"` } +func (x *ElicitParams) UnmarshalJSON(data []byte) error { + type params ElicitParams // avoid recursion + return unmarshalSchemaAndMeta(data, (*params)(x)) +} + func (x *ElicitParams) isParams() {} func (x *ElicitParams) isInputRequest() {} func (x *ElicitParams) isNil() bool { return x == nil } diff --git a/mcp/protocol_numbers_test.go b/mcp/protocol_numbers_test.go new file mode 100644 index 00000000..e7cca852 --- /dev/null +++ b/mcp/protocol_numbers_test.go @@ -0,0 +1,364 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// exactNumbers are JSON numbers that do not survive a float64 round trip: +// the integers and the long decimal lose precision, and the exponent form is +// rewritten. +var exactNumbers = []string{ + "9007199254740993", // 2^53 + 1 + "-9007199254740993", + "18446744073709551615", // 2^64 - 1 + "0.12345678901234567890123456789", + "1.2300e+12", +} + +// numericSchema returns a form schema with n in several numeric keywords. +func numericSchema(n string) string { + return fmt.Sprintf(`{"type":"object","properties":{"n":{"type":"number","minimum":%[1]s,"maximum":%[1]s,"enum":[%[1]s],"default":%[1]s}}}`, n) +} + +// numericMeta returns metadata with n at the top level and nested. +func numericMeta(n string) string { + return fmt.Sprintf(`{"n":%[1]s,"nested":{"values":[%[1]s]}}`, n) +} + +// decodeExactly decodes data with numbers as json.Number, the representation +// the SDK is expected to produce. +func decodeExactly(t *testing.T, data string) any { + t.Helper() + dec := json.NewDecoder(strings.NewReader(data)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + t.Fatal(err) + } + return v +} + +// checkExact reports whether got holds the value of the JSON text want, with +// every number represented as the same json.Number. +func checkExact(t *testing.T, what string, got any, want string) { + t.Helper() + if m, ok := got.(Meta); ok { + got = map[string]any(m) + } + if diff := cmp.Diff(decodeExactly(t, want), got); diff != "" { + t.Errorf("%s mismatch (-want +got):\n%s", what, diff) + } +} + +func TestSchemaAndMetaNumbersDecodeExactly(t *testing.T) { + // 1e400 is outside the range of float64, so before numbers were decoded + // as json.Number it made the whole message undecodable. + for _, n := range slices.Concat(exactNumbers, []string{"1e400"}) { + schema, meta := numericSchema(n), numericMeta(n) + tool := fmt.Sprintf(`{"name":"exact","inputSchema":%[1]s,"outputSchema":%[1]s,"_meta":%[2]s}`, schema, meta) + elicit := fmt.Sprintf(`{"mode":"form","message":"m","requestedSchema":%s,"_meta":%s}`, schema, meta) + checkTool := func(t *testing.T, got *Tool) { + t.Helper() + checkExact(t, "InputSchema", got.InputSchema, schema) + checkExact(t, "OutputSchema", got.OutputSchema, schema) + checkExact(t, "Meta", got.Meta, meta) + } + checkElicit := func(t *testing.T, got *ElicitParams) { + t.Helper() + checkExact(t, "RequestedSchema", got.RequestedSchema, schema) + checkExact(t, "Meta", got.Meta, meta) + } + unmarshal := func(t *testing.T, data string, v any) { + t.Helper() + if err := json.Unmarshal([]byte(data), v); err != nil { + t.Fatal(err) + } + } + + t.Run(n, func(t *testing.T) { + t.Run("Tool", func(t *testing.T) { + var got Tool + unmarshal(t, tool, &got) + checkTool(t, &got) + }) + t.Run("ListToolsResult", func(t *testing.T) { + var got ListToolsResult + unmarshal(t, `{"tools":[`+tool+`]}`, &got) + checkTool(t, got.Tools[0]) + }) + t.Run("CreateMessageWithToolsParams", func(t *testing.T) { + var got CreateMessageWithToolsParams + unmarshal(t, `{"maxTokens":1,"messages":[],"tools":[`+tool+`]}`, &got) + checkTool(t, got.Tools[0]) + }) + t.Run("CallToolResult", func(t *testing.T) { + var got CallToolResult + unmarshal(t, fmt.Sprintf(`{"_meta":%[1]s,"content":[`+ + `{"type":"text","text":"t","_meta":%[1]s},`+ + `{"type":"resource_link","uri":"u","name":"n","_meta":%[1]s},`+ + `{"type":"resource","resource":{"uri":"u","text":"t","_meta":%[1]s},"_meta":%[1]s}]}`, meta), &got) + checkExact(t, "Meta", got.Meta, meta) + checkExact(t, "TextContent.Meta", got.Content[0].(*TextContent).Meta, meta) + checkExact(t, "ResourceLink.Meta", got.Content[1].(*ResourceLink).Meta, meta) + embedded := got.Content[2].(*EmbeddedResource) + checkExact(t, "EmbeddedResource.Meta", embedded.Meta, meta) + checkExact(t, "ResourceContents.Meta", embedded.Resource.Meta, meta) + }) + t.Run("ElicitParams", func(t *testing.T) { + var got ElicitParams + unmarshal(t, elicit, &got) + checkElicit(t, &got) + }) + t.Run("InputRequestMap", func(t *testing.T) { + var got InputRequestMap + unmarshal(t, `{"r":{"method":"elicitation/create","params":`+elicit+`}}`, &got) + params, ok := got["r"].(*ElicitParams) + if !ok { + t.Fatalf("input request is %T, want *ElicitParams", got["r"]) + } + checkElicit(t, params) + }) + }) + } +} + +func TestSchemaAndMetaFloat64Compatibility(t *testing.T) { + prev := schemaandmetafloat64 + schemaandmetafloat64 = "1" + t.Cleanup(func() { schemaandmetafloat64 = prev }) + + const n = "9007199254740993" + const rounded = float64(9007199254740992) + schema, meta := numericSchema(n), numericMeta(n) + minimum := func(schema any) any { + return schema.(map[string]any)["properties"].(map[string]any)["n"].(map[string]any)["minimum"] + } + check := func(what string, got any) { + t.Helper() + if got != rounded { + t.Errorf("%s = %v (%T), want rounded float64 value", what, got, got) + } + } + + var tool Tool + if err := json.Unmarshal([]byte(fmt.Sprintf(`{"name":"t","inputSchema":%[1]s,"outputSchema":%[1]s,"_meta":%[2]s}`, schema, meta)), &tool); err != nil { + t.Fatal(err) + } + check("Tool.InputSchema", minimum(tool.InputSchema)) + check("Tool.OutputSchema", minimum(tool.OutputSchema)) + check("Tool.Meta", tool.Meta["n"]) + + var result CallToolResult + if err := json.Unmarshal([]byte(fmt.Sprintf(`{"_meta":%[1]s,"content":[{"type":"text","text":"t","_meta":%[1]s}],"structuredContent":{"n":%[2]s}}`, meta, n)), &result); err != nil { + t.Fatal(err) + } + check("CallToolResult.Meta", result.Meta["n"]) + check("TextContent.Meta", result.Content[0].(*TextContent).Meta["n"]) + // StructuredContent has its own compatibility parameter. + if got := result.StructuredContent.(map[string]any)["n"]; got != json.Number(n) { + t.Errorf("StructuredContent = %v (%T), want json.Number", got, got) + } + + var elicit ElicitParams + if err := json.Unmarshal([]byte(fmt.Sprintf(`{"message":"m","requestedSchema":%s,"_meta":%s}`, schema, meta)), &elicit); err != nil { + t.Fatal(err) + } + check("ElicitParams.RequestedSchema", minimum(elicit.RequestedSchema)) + check("ElicitParams.Meta", elicit.Meta["n"]) +} + +// TestSchemaAndMetaDecodeInPlace checks that Tool and ElicitParams still +// decode the way encoding/json decodes a struct: fields absent from the input +// keep their values, and null leaves the value unchanged. +func TestSchemaAndMetaDecodeInPlace(t *testing.T) { + tool := Tool{Name: "kept", Description: "old"} + if err := json.Unmarshal([]byte(`{"description":"new"}`), &tool); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(`null`), &tool); err != nil { + t.Fatal(err) + } + if want := (Tool{Name: "kept", Description: "new"}); !cmp.Equal(tool, want) { + t.Errorf("Tool = %+v, want %+v", tool, want) + } + + elicit := ElicitParams{Mode: "form", Message: "old"} + if err := json.Unmarshal([]byte(`{"message":"new"}`), &elicit); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(`null`), &elicit); err != nil { + t.Fatal(err) + } + if want := (ElicitParams{Mode: "form", Message: "new"}); !cmp.Equal(elicit, want) { + t.Errorf("ElicitParams = %+v, want %+v", elicit, want) + } +} + +// TestSchemaAndMetaNumbersAcrossTransports checks the numbers a client +// receives in tools/list, tools/call and elicitation/create. +func TestSchemaAndMetaNumbersAcrossTransports(t *testing.T) { + const n = "9007199254740993" + schema, meta := numericSchema(n), numericMeta(n) + rawMeta := func() Meta { + var m map[string]json.RawMessage + if err := json.Unmarshal([]byte(meta), &m); err != nil { + t.Fatal(err) + } + out := Meta{} + for k, v := range m { + out[k] = v + } + return out + } + + newServer := func() *Server { + server := NewServer(testImpl, nil) + server.AddTool(&Tool{ + Name: "exact", + InputSchema: json.RawMessage(schema), + OutputSchema: json.RawMessage(schema), + Meta: rawMeta(), + }, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + if _, err := req.Session.Elicit(ctx, &ElicitParams{ + Mode: "form", + Message: "m", + RequestedSchema: json.RawMessage(schema), + Meta: rawMeta(), + }); err != nil { + return nil, err + } + return &CallToolResult{ + Meta: rawMeta(), + Content: []Content{&TextContent{Text: "t", Meta: rawMeta()}}, + StructuredContent: json.RawMessage(`{"n":` + n + `}`), + }, nil + }) + return server + } + + transports := map[string]func(t *testing.T, server *Server) Transport{ + "in-memory": func(t *testing.T, server *Server) Transport { + ct, st := NewInMemoryTransports() + ss, err := server.Connect(t.Context(), st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ss.Close() }) + return ct + }, + "streamable": func(t *testing.T, server *Server) Transport { + hs := httptest.NewServer(NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil)) + t.Cleanup(hs.Close) + return &StreamableClientTransport{Endpoint: hs.URL} + }, + "streamable JSON response": func(t *testing.T, server *Server) Transport { + hs := httptest.NewServer(NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{JSONResponse: true})) + t.Cleanup(hs.Close) + return &StreamableClientTransport{Endpoint: hs.URL} + }, + } + for name, transport := range transports { + t.Run(name, func(t *testing.T) { + elicited := make(chan *ElicitParams, 1) + client := NewClient(testImpl, &ClientOptions{ + ElicitationHandler: func(_ context.Context, req *ElicitRequest) (*ElicitResult, error) { + elicited <- req.Params + return &ElicitResult{Action: "decline"}, nil + }, + }) + // The server sends elicitation/create while serving tools/call, which + // 2026-07-28 replaces with input requests (tested below). + opts := &ClientSessionOptions{ProtocolVersion: protocolVersion20251125} + cs, err := client.Connect(t.Context(), transport(t, newServer()), opts) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cs.Close() }) + + tools, err := cs.ListTools(t.Context(), nil) + if err != nil { + t.Fatal(err) + } + checkExact(t, "Tool.InputSchema", tools.Tools[0].InputSchema, schema) + checkExact(t, "Tool.OutputSchema", tools.Tools[0].OutputSchema, schema) + checkExact(t, "Tool.Meta", tools.Tools[0].Meta, meta) + + result, err := cs.CallTool(t.Context(), &CallToolParams{Name: "exact"}) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("CallTool returned a tool error: %v", result.Content) + } + checkExact(t, "CallToolResult.Meta", result.Meta, meta) + checkExact(t, "TextContent.Meta", result.Content[0].(*TextContent).Meta, meta) + checkExact(t, "StructuredContent", result.StructuredContent, `{"n":`+n+`}`) + select { + case params := <-elicited: + checkExact(t, "ElicitParams.RequestedSchema", params.RequestedSchema, schema) + checkExact(t, "ElicitParams.Meta", params.Meta, meta) + default: + t.Fatal("the elicitation handler was not called") + } + }) + } +} + +// TestSchemaAndMetaNumbersInInputRequests checks the numbers a client receives +// in the elicitation input requests of a multi round-trip tool call. +func TestSchemaAndMetaNumbersInInputRequests(t *testing.T) { + const n = "9007199254740993" + schema := numericSchema(n) + for _, jsonResponse := range []bool{false, true} { + t.Run(fmt.Sprintf("JSONResponse=%t", jsonResponse), func(t *testing.T) { + server := NewServer(testImpl, nil) + server.AddTool(&Tool{Name: "input", InputSchema: json.RawMessage(`{"type":"object"}`)}, func(context.Context, *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{ + InputRequests: InputRequestMap{"form": &ElicitParams{ + Mode: "form", + Message: "m", + RequestedSchema: json.RawMessage(schema), + Meta: Meta{"n": json.RawMessage(n)}, + }}, + RequestState: "state", + }, nil + }) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{Stateless: true, JSONResponse: jsonResponse}) + hs := httptest.NewServer(handler) + t.Cleanup(hs.Close) + client := NewClient(testImpl, &ClientOptions{MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}}) + cs, err := client.Connect(t.Context(), &StreamableClientTransport{Endpoint: hs.URL}, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cs.Close() }) + + result, err := cs.CallTool(t.Context(), &CallToolParams{Name: "input"}) + if err != nil { + t.Fatal(err) + } + if !result.NeedsInput() { + t.Fatalf("CallTool result does not need input: %+v", result) + } + params, ok := result.InputRequests["form"].(*ElicitParams) + if !ok { + t.Fatalf("input request is %T, want *ElicitParams", result.InputRequests["form"]) + } + checkExact(t, "ElicitParams.RequestedSchema", params.RequestedSchema, schema) + checkExact(t, "ElicitParams.Meta", params.Meta, `{"n":`+n+`}`) + }) + } +}