From 874dc56e242930e4b0a9c7cf42900ff0068673da Mon Sep 17 00:00:00 2001 From: tangerg Date: Mon, 28 Sep 2026 18:55:47 +0800 Subject: [PATCH 1/3] mcp: preserve numbers in tool schemas and metadata --- mcp/protocol.go | 17 +++- mcp/protocol_number_precision_test.go | 113 ++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 mcp/protocol_number_precision_test.go diff --git a/mcp/protocol.go b/mcp/protocol.go index d67f66c3e..5feaedb63 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -268,6 +268,8 @@ type CallToolParamsRaw struct { } // A CallToolResult is the server's response to a tool call. +// Numbers in metadata (including content metadata) received from JSON are +// represented as [json.Number] to preserve their exact values. // // The [ToolHandler] and [ToolHandlerFor] handler functions return this result, // though [ToolHandlerFor] populates much of it automatically as documented at @@ -413,7 +415,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 := internaljson.UnmarshalUseNumber(data, &wire); err != nil { return err } if len(wire.StructuredContent) > 0 { @@ -1934,6 +1936,7 @@ func (x *SetLoggingLevelParams) GetProgressToken() any { return getProgressToke func (x *SetLoggingLevelParams) SetProgressToken(t any) { setProgressToken(x, t) } // Definition for a tool the client can call. +// Numbers in schemas and metadata received from JSON are represented as [json.Number]. type Tool struct { // See [specification/2025-06-18/basic/index#general-fields] for notes on _meta // usage. @@ -1959,7 +1962,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). Numbers are 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). @@ -1977,7 +1981,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). Numbers are 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. @@ -1988,6 +1993,12 @@ type Tool struct { Icons []Icon `json:"icons,omitempty"` } +// UnmarshalJSON preserves numbers in tool schemas and metadata. +func (t *Tool) UnmarshalJSON(data []byte) error { + type tool Tool + return internaljson.UnmarshalUseNumber(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. diff --git a/mcp/protocol_number_precision_test.go b/mcp/protocol_number_precision_test.go new file mode 100644 index 000000000..54d8b45ec --- /dev/null +++ b/mcp/protocol_number_precision_test.go @@ -0,0 +1,113 @@ +// 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" + "testing" +) + +func TestProtocolNumberPrecisionAcrossTransport(t *testing.T) { + for _, transport := range []string{"ndjson", "http"} { + t.Run(transport, func(t *testing.T) { + for _, literal := range []string{"9007199254740993", "-9007199254740993", "0.12345678901234567890123456789", "18446744073709551615"} { + t.Run(literal, func(t *testing.T) { + schema := json.RawMessage(fmt.Sprintf(`{"type":"object","properties":{"id":{"const":%s,"enum":[%s],"minimum":%s,"maximum":%s}},"required":["id"]}`, literal, literal, literal, literal)) + payload := json.RawMessage(fmt.Sprintf(`{"id":%s}`, literal)) + meta := Meta{"test/value": json.RawMessage(literal), "test/nested": json.RawMessage(fmt.Sprintf(`{"values":[%s]}`, literal))} + server := NewServer(&Implementation{Name: "test", Version: "1"}, nil) + server.AddTool(&Tool{Name: "exact", InputSchema: schema, OutputSchema: schema, Meta: meta}, func(context.Context, *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{Meta: meta, StructuredContent: payload, Content: []Content{ + &TextContent{Text: "ok", Meta: meta}, + &ResourceLink{URI: "test://exact", Name: "exact", Meta: meta}, + &EmbeddedResource{Meta: meta, Resource: &ResourceContents{URI: "test://exact", Text: "ok", Meta: meta}}, + }}, nil + }) + var clientTransport Transport + if transport == "http" { + hs := httptest.NewServer(NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil)) + defer hs.Close() + clientTransport = &StreamableClientTransport{Endpoint: hs.URL} + } else { + st, ct := NewInMemoryTransports() + ss, err := server.Connect(t.Context(), st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + clientTransport = ct + } + client := NewClient(&Implementation{Name: "test", Version: "1"}, nil) + cs, err := client.Connect(t.Context(), clientTransport, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + list, err := cs.ListTools(t.Context(), nil) + if err != nil { + t.Fatal(err) + } + result, err := cs.CallTool(t.Context(), &CallToolParams{Name: "exact", Arguments: payload}) + if err != nil { + t.Fatal(err) + } + check := func(name string, value any, want string) { + t.Helper() + t.Run(name, func(t *testing.T) { + got, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Errorf("received %s; want %s", got, want) + } + }) + } + for name, s := range map[string]any{"input_schema": list.Tools[0].InputSchema, "output_schema": list.Tools[0].OutputSchema} { + constraints := s.(map[string]any)["properties"].(map[string]any)["id"].(map[string]any) + for _, keyword := range []string{"const", "minimum", "maximum"} { + check(name+"_"+keyword, constraints[keyword], literal) + } + check(name+"_enum", constraints["enum"], "["+literal+"]") + } + for name, m := range map[string]Meta{ + "tool_meta": list.Tools[0].Meta, + "result_meta": result.Meta, + "text_meta": result.Content[0].(*TextContent).Meta, + "link_meta": result.Content[1].(*ResourceLink).Meta, + "embedded_meta": result.Content[2].(*EmbeddedResource).Meta, + "resource_meta": result.Content[2].(*EmbeddedResource).Resource.Meta, + } { + check(name, m["test/value"], literal) + check(name+"_nested", m["test/nested"], fmt.Sprintf(`{"values":[%s]}`, literal)) + } + check("structured_content_control", result.StructuredContent, string(payload)) + }) + } + }) + } +} + +func TestToolNumberPrecision(t *testing.T) { + const data = `{"name":"exact","inputSchema":{"type":"object","properties":{"id":{"const":1e400}}},"_meta":{"revision":9007199254740993}}` + var tool Tool + if err := json.Unmarshal([]byte(data), &tool); err != nil { + t.Fatal(err) + } + got := tool.InputSchema.(map[string]any)["properties"].(map[string]any)["id"].(map[string]any)["const"] + if got != json.Number("1e400") { + t.Errorf("const = %v (%T), want json.Number(1e400)", got, got) + } + if got := tool.Meta["revision"]; got != json.Number("9007199254740993") { + t.Errorf("revision = %v (%T), want exact json.Number", got, got) + } + if tool.Name != "exact" || tool.OutputSchema != nil { + t.Errorf("unexpected tool fields: %+v", tool) + } +} From c107509e29ada648362e57deb1a7c6e5c69cdcdb Mon Sep 17 00:00:00 2001 From: tangerg Date: Thu, 5 Nov 2026 17:34:59 +0800 Subject: [PATCH 2/3] mcp: preserve numbers in elicitation schemas and metadata ElicitParams decoded with the default float64 representation, so numeric constraints in a requested schema and numbers in _meta above 2^53 or with long fractions were rounded before the client saw them. Decode with UseNumber, as tool schemas and metadata now do. Decoding into a separate value also leaves the receiver untouched when the input is malformed, instead of half-populated. Co-Authored-By: Claude Opus 5.5 --- mcp/elicitation_numbers_test.go | 148 ++++++++++++++++++++++++++++++++ mcp/protocol.go | 14 ++- 2 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 mcp/elicitation_numbers_test.go diff --git a/mcp/elicitation_numbers_test.go b/mcp/elicitation_numbers_test.go new file mode 100644 index 000000000..a9cccf484 --- /dev/null +++ b/mcp/elicitation_numbers_test.go @@ -0,0 +1,148 @@ +// Copyright 2025 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" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" +) + +const elicitationNumericSchema = `{ + "type":"object", + "properties":{ + "large":{"type":"integer","minimum":9007199254740993,"maximum":922337203685477580812345678901,"enum":[9007199254740993,922337203685477580812345678901],"default":9007199254740993}, + "decimal":{"type":"number","minimum":0.12345678901234567890123456789,"maximum":1.2300e+12,"enum":[0.12345678901234567890123456789,1.2300e+12],"default":0.12345678901234567890123456789} + }, + "required":["large","decimal"] +}` + +func TestElicitParamsJSONPreservesNumbers(t *testing.T) { + for _, decoder := range []struct { + name string + unmarshal func([]byte, any) error + }{ + {"standard JSON", json.Unmarshal}, + {"SDK JSON", internaljson.Unmarshal}, + } { + for _, wrapped := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/input-map=%t", decoder.name, wrapped), func(t *testing.T) { + data := `{"mode":"form","message":"Enter values","requestedSchema":` + elicitationNumericSchema + `,"_meta":{"cursor":9007199254740993,"amount":0.12345678901234567890123456789}}` + params := new(ElicitParams) + if wrapped { + var requests InputRequestMap + if err := decoder.unmarshal([]byte(`{"form":{"method":"elicitation/create","params":`+data+`}}`), &requests); err != nil { + t.Fatal(err) + } + var ok bool + params, ok = requests["form"].(*ElicitParams) + if !ok { + t.Fatalf("input request = %T, want *ElicitParams", requests["form"]) + } + } else if err := decoder.unmarshal([]byte(data), params); err != nil { + t.Fatal(err) + } + assertElicitationExactValue(t, params.RequestedSchema, elicitationNumericSchema) + if params.Mode != "form" || params.Message != "Enter values" || params.Meta["cursor"] != json.Number("9007199254740993") || params.Meta["amount"] != json.Number("0.12345678901234567890123456789") { + t.Fatalf("elicitation params = %#v", params) + } + encoded, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + var roundtrip ElicitParams + if err := decoder.unmarshal(encoded, &roundtrip); err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(params, &roundtrip); diff != "" { + t.Errorf("round trip changed elicitation params (-want +got):\n%s", diff) + } + }) + } + } +} + +func TestElicitParamsJSONDecodeIsTransactional(t *testing.T) { + params := &ElicitParams{Mode: "form", Message: "original", RequestedSchema: map[string]any{"default": json.Number("9007199254740993")}, Meta: Meta{"original": json.RawMessage("9007199254740993")}} + before, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(`{"requestedSchema":{"default":1},"_meta":{"changed":1},"message":false}`), params); err == nil { + t.Fatal("invalid JSON field type was accepted") + } + after, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Errorf("failed decode changed receiver:\nbefore: %s\nafter: %s", before, after) + } +} + +func TestMultiRoundTripHTTPElicitParamsPreserveNumbers(t *testing.T) { + for _, jsonResponse := range []bool{false, true} { + t.Run(fmt.Sprintf("JSON response=%t", jsonResponse), func(t *testing.T) { + server := NewServer(testImpl, nil) + server.AddTool(&Tool{Name: "numbers", InputSchema: json.RawMessage(`{"type":"object"}`)}, func(context.Context, *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{ + InputRequests: InputRequestMap{"form": &ElicitParams{ + Mode: "form", Message: "Enter values", RequestedSchema: json.RawMessage(elicitationNumericSchema), + Meta: Meta{"cursor": json.RawMessage("9007199254740993")}, + }}, + RequestState: "form-state", + }, nil + }) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{Stateless: true, JSONResponse: jsonResponse}) + httpServer := httptest.NewServer(handler) + t.Cleanup(httpServer.Close) + client := NewClient(testImpl, &ClientOptions{MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}}) + session, err := client.Connect(t.Context(), &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { session.Close() }) + if version := session.InitializeResult().ProtocolVersion; version != protocolVersion20260728 { + t.Fatalf("protocol version = %q, want %q", version, protocolVersion20260728) + } + response, err := session.CallTool(t.Context(), &CallToolParams{Name: "numbers"}) + if err != nil { + t.Fatal(err) + } + if !response.NeedsInput() || response.RequestState != "form-state" { + t.Fatalf("initial call did not require the expected form input: %#v", response) + } + params, ok := response.InputRequests["form"].(*ElicitParams) + if !ok { + t.Fatalf("input request = %T, want *ElicitParams", response.InputRequests["form"]) + } + assertElicitationExactValue(t, params.RequestedSchema, elicitationNumericSchema) + if params.Meta["cursor"] != json.Number("9007199254740993") { + t.Fatalf("input request metadata = %v", params.Meta["cursor"]) + } + }) + } +} + +func assertElicitationExactValue(t *testing.T, value any, data string) { + t.Helper() + decoder := json.NewDecoder(strings.NewReader(data)) + decoder.UseNumber() + var want any + if err := decoder.Decode(&want); err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(want, value); diff != "" { + t.Errorf("elicitation JSON changed values or numeric types (-want +got):\n%s", diff) + } +} diff --git a/mcp/protocol.go b/mcp/protocol.go index b11244172..5d8b88f15 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -2253,8 +2253,8 @@ type ElicitParams struct { // Internally, the SDK uses github.com/google/jsonschema-go for validation, // 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). + // From the client, this field holds the decoded schema with json.Number + // values, preserving numeric constraints without floating-point rounding. // // Only top-level properties are allowed, without nesting. // @@ -2270,6 +2270,16 @@ type ElicitParams struct { ElicitationID string `json:"elicitationId,omitempty"` } +func (x *ElicitParams) UnmarshalJSON(data []byte) error { + type wireElicitParams ElicitParams + var wire wireElicitParams + if err := internaljson.UnmarshalUseNumber(data, &wire); err != nil { + return err + } + *x = ElicitParams(wire) + return nil +} + func (x *ElicitParams) isParams() {} func (x *ElicitParams) isInputRequest() {} func (x *ElicitParams) isNil() bool { return x == nil } From 57e00be593d38380baf7e41521512e66bfa62d53 Mon Sep 17 00:00:00 2001 From: tangerg Date: Fri, 6 Nov 2026 17:18:51 +0800 Subject: [PATCH 3/3] mcp: gate exact schema and metadata numbers behind MCPGODEBUG Decoding schemas and metadata with UseNumber changes the dynamic type of their numbers from float64 to json.Number, a behavioral change for existing users. As with structuredcontentfloat64, MCPGODEBUG=schemaandmetafloat64=1 restores float64 decoding until 1.11.0. Tool and ElicitParams decode in place, so apart from how numbers are represented they decode exactly as encoding/json did before: fields absent from the input keep their values, and null is a no-op. The tests are consolidated in protocol_numbers_test.go. They cover each type that decodes these values, all three transports, input requests, and the compatibility parameter. --- docs/mcpgodebug.md | 7 + internal/docs/mcpgodebug.src.md | 7 + mcp/elicitation_numbers_test.go | 148 ----------- mcp/protocol.go | 50 ++-- mcp/protocol_number_precision_test.go | 113 -------- mcp/protocol_numbers_test.go | 364 ++++++++++++++++++++++++++ 6 files changed, 410 insertions(+), 279 deletions(-) delete mode 100644 mcp/elicitation_numbers_test.go delete mode 100644 mcp/protocol_number_precision_test.go create mode 100644 mcp/protocol_numbers_test.go diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 475635239..144d9cebd 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 66f36ff2d..db5e9112f 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/elicitation_numbers_test.go b/mcp/elicitation_numbers_test.go deleted file mode 100644 index a9cccf484..000000000 --- a/mcp/elicitation_numbers_test.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright 2025 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" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - - internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" -) - -const elicitationNumericSchema = `{ - "type":"object", - "properties":{ - "large":{"type":"integer","minimum":9007199254740993,"maximum":922337203685477580812345678901,"enum":[9007199254740993,922337203685477580812345678901],"default":9007199254740993}, - "decimal":{"type":"number","minimum":0.12345678901234567890123456789,"maximum":1.2300e+12,"enum":[0.12345678901234567890123456789,1.2300e+12],"default":0.12345678901234567890123456789} - }, - "required":["large","decimal"] -}` - -func TestElicitParamsJSONPreservesNumbers(t *testing.T) { - for _, decoder := range []struct { - name string - unmarshal func([]byte, any) error - }{ - {"standard JSON", json.Unmarshal}, - {"SDK JSON", internaljson.Unmarshal}, - } { - for _, wrapped := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/input-map=%t", decoder.name, wrapped), func(t *testing.T) { - data := `{"mode":"form","message":"Enter values","requestedSchema":` + elicitationNumericSchema + `,"_meta":{"cursor":9007199254740993,"amount":0.12345678901234567890123456789}}` - params := new(ElicitParams) - if wrapped { - var requests InputRequestMap - if err := decoder.unmarshal([]byte(`{"form":{"method":"elicitation/create","params":`+data+`}}`), &requests); err != nil { - t.Fatal(err) - } - var ok bool - params, ok = requests["form"].(*ElicitParams) - if !ok { - t.Fatalf("input request = %T, want *ElicitParams", requests["form"]) - } - } else if err := decoder.unmarshal([]byte(data), params); err != nil { - t.Fatal(err) - } - assertElicitationExactValue(t, params.RequestedSchema, elicitationNumericSchema) - if params.Mode != "form" || params.Message != "Enter values" || params.Meta["cursor"] != json.Number("9007199254740993") || params.Meta["amount"] != json.Number("0.12345678901234567890123456789") { - t.Fatalf("elicitation params = %#v", params) - } - encoded, err := json.Marshal(params) - if err != nil { - t.Fatal(err) - } - var roundtrip ElicitParams - if err := decoder.unmarshal(encoded, &roundtrip); err != nil { - t.Fatal(err) - } - if diff := cmp.Diff(params, &roundtrip); diff != "" { - t.Errorf("round trip changed elicitation params (-want +got):\n%s", diff) - } - }) - } - } -} - -func TestElicitParamsJSONDecodeIsTransactional(t *testing.T) { - params := &ElicitParams{Mode: "form", Message: "original", RequestedSchema: map[string]any{"default": json.Number("9007199254740993")}, Meta: Meta{"original": json.RawMessage("9007199254740993")}} - before, err := json.Marshal(params) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal([]byte(`{"requestedSchema":{"default":1},"_meta":{"changed":1},"message":false}`), params); err == nil { - t.Fatal("invalid JSON field type was accepted") - } - after, err := json.Marshal(params) - if err != nil { - t.Fatal(err) - } - if string(after) != string(before) { - t.Errorf("failed decode changed receiver:\nbefore: %s\nafter: %s", before, after) - } -} - -func TestMultiRoundTripHTTPElicitParamsPreserveNumbers(t *testing.T) { - for _, jsonResponse := range []bool{false, true} { - t.Run(fmt.Sprintf("JSON response=%t", jsonResponse), func(t *testing.T) { - server := NewServer(testImpl, nil) - server.AddTool(&Tool{Name: "numbers", InputSchema: json.RawMessage(`{"type":"object"}`)}, func(context.Context, *CallToolRequest) (*CallToolResult, error) { - return &CallToolResult{ - InputRequests: InputRequestMap{"form": &ElicitParams{ - Mode: "form", Message: "Enter values", RequestedSchema: json.RawMessage(elicitationNumericSchema), - Meta: Meta{"cursor": json.RawMessage("9007199254740993")}, - }}, - RequestState: "form-state", - }, nil - }) - handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{Stateless: true, JSONResponse: jsonResponse}) - httpServer := httptest.NewServer(handler) - t.Cleanup(httpServer.Close) - client := NewClient(testImpl, &ClientOptions{MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}}) - session, err := client.Connect(t.Context(), &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { session.Close() }) - if version := session.InitializeResult().ProtocolVersion; version != protocolVersion20260728 { - t.Fatalf("protocol version = %q, want %q", version, protocolVersion20260728) - } - response, err := session.CallTool(t.Context(), &CallToolParams{Name: "numbers"}) - if err != nil { - t.Fatal(err) - } - if !response.NeedsInput() || response.RequestState != "form-state" { - t.Fatalf("initial call did not require the expected form input: %#v", response) - } - params, ok := response.InputRequests["form"].(*ElicitParams) - if !ok { - t.Fatalf("input request = %T, want *ElicitParams", response.InputRequests["form"]) - } - assertElicitationExactValue(t, params.RequestedSchema, elicitationNumericSchema) - if params.Meta["cursor"] != json.Number("9007199254740993") { - t.Fatalf("input request metadata = %v", params.Meta["cursor"]) - } - }) - } -} - -func assertElicitationExactValue(t *testing.T, value any, data string) { - t.Helper() - decoder := json.NewDecoder(strings.NewReader(data)) - decoder.UseNumber() - var want any - if err := decoder.Decode(&want); err != nil { - t.Fatal(err) - } - if diff := cmp.Diff(want, value); diff != "" { - t.Errorf("elicitation JSON changed values or numeric types (-want +got):\n%s", diff) - } -} diff --git a/mcp/protocol.go b/mcp/protocol.go index 5d8b88f15..134e870cb 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -326,8 +326,6 @@ type CallToolParamsRaw struct { } // A CallToolResult is the server's response to a tool call. -// Numbers in metadata (including content metadata) received from JSON are -// represented as [json.Number] to preserve their exact values. // // The [ToolHandler] and [ToolHandlerFor] handler functions return this result, // though [ToolHandlerFor] populates much of it automatically as documented at @@ -335,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 @@ -447,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 { @@ -473,7 +489,7 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error { StructuredContent json.RawMessage `json:"structuredContent"` ResultType resultType `json:"resultType"` } - if err := internaljson.UnmarshalUseNumber(data, &wire); err != nil { + if err := unmarshalSchemaAndMeta(data, &wire); err != nil { return err } if len(wire.StructuredContent) > 0 { @@ -1999,10 +2015,11 @@ func (x *SetLoggingLevelParams) GetProgressToken() any { return getProgressToke func (x *SetLoggingLevelParams) SetProgressToken(t any) { setProgressToken(x, t) } // Definition for a tool the client can call. -// Numbers in schemas and metadata received from JSON are represented as [json.Number]. 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. // @@ -2025,7 +2042,7 @@ 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). Numbers are represented as + // 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 @@ -2044,7 +2061,7 @@ 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). Numbers are represented as + // 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 @@ -2056,10 +2073,9 @@ type Tool struct { Icons []Icon `json:"icons,omitempty"` } -// UnmarshalJSON preserves numbers in tool schemas and metadata. func (t *Tool) UnmarshalJSON(data []byte) error { - type tool Tool - return internaljson.UnmarshalUseNumber(data, (*tool)(t)) + type tool Tool // avoid recursion + return unmarshalSchemaAndMeta(data, (*tool)(t)) } // hintomitempty is a compatibility parameter that restores the pre-1.7.0 @@ -2239,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. // @@ -2253,8 +2271,9 @@ type ElicitParams struct { // Internally, the SDK uses github.com/google/jsonschema-go for validation, // which only supports the 2020-12 draft of the JSON schema spec. // - // From the client, this field holds the decoded schema with json.Number - // values, preserving numeric constraints without floating-point rounding. + // From the client, this field will use the default JSON marshaling (a + // map[string]any), with numbers represented as [json.Number] to preserve + // their exact values. // // Only top-level properties are allowed, without nesting. // @@ -2271,13 +2290,8 @@ type ElicitParams struct { } func (x *ElicitParams) UnmarshalJSON(data []byte) error { - type wireElicitParams ElicitParams - var wire wireElicitParams - if err := internaljson.UnmarshalUseNumber(data, &wire); err != nil { - return err - } - *x = ElicitParams(wire) - return nil + type params ElicitParams // avoid recursion + return unmarshalSchemaAndMeta(data, (*params)(x)) } func (x *ElicitParams) isParams() {} diff --git a/mcp/protocol_number_precision_test.go b/mcp/protocol_number_precision_test.go deleted file mode 100644 index 54d8b45ec..000000000 --- a/mcp/protocol_number_precision_test.go +++ /dev/null @@ -1,113 +0,0 @@ -// 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" - "testing" -) - -func TestProtocolNumberPrecisionAcrossTransport(t *testing.T) { - for _, transport := range []string{"ndjson", "http"} { - t.Run(transport, func(t *testing.T) { - for _, literal := range []string{"9007199254740993", "-9007199254740993", "0.12345678901234567890123456789", "18446744073709551615"} { - t.Run(literal, func(t *testing.T) { - schema := json.RawMessage(fmt.Sprintf(`{"type":"object","properties":{"id":{"const":%s,"enum":[%s],"minimum":%s,"maximum":%s}},"required":["id"]}`, literal, literal, literal, literal)) - payload := json.RawMessage(fmt.Sprintf(`{"id":%s}`, literal)) - meta := Meta{"test/value": json.RawMessage(literal), "test/nested": json.RawMessage(fmt.Sprintf(`{"values":[%s]}`, literal))} - server := NewServer(&Implementation{Name: "test", Version: "1"}, nil) - server.AddTool(&Tool{Name: "exact", InputSchema: schema, OutputSchema: schema, Meta: meta}, func(context.Context, *CallToolRequest) (*CallToolResult, error) { - return &CallToolResult{Meta: meta, StructuredContent: payload, Content: []Content{ - &TextContent{Text: "ok", Meta: meta}, - &ResourceLink{URI: "test://exact", Name: "exact", Meta: meta}, - &EmbeddedResource{Meta: meta, Resource: &ResourceContents{URI: "test://exact", Text: "ok", Meta: meta}}, - }}, nil - }) - var clientTransport Transport - if transport == "http" { - hs := httptest.NewServer(NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil)) - defer hs.Close() - clientTransport = &StreamableClientTransport{Endpoint: hs.URL} - } else { - st, ct := NewInMemoryTransports() - ss, err := server.Connect(t.Context(), st, nil) - if err != nil { - t.Fatal(err) - } - defer ss.Close() - clientTransport = ct - } - client := NewClient(&Implementation{Name: "test", Version: "1"}, nil) - cs, err := client.Connect(t.Context(), clientTransport, nil) - if err != nil { - t.Fatal(err) - } - defer cs.Close() - list, err := cs.ListTools(t.Context(), nil) - if err != nil { - t.Fatal(err) - } - result, err := cs.CallTool(t.Context(), &CallToolParams{Name: "exact", Arguments: payload}) - if err != nil { - t.Fatal(err) - } - check := func(name string, value any, want string) { - t.Helper() - t.Run(name, func(t *testing.T) { - got, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - if string(got) != want { - t.Errorf("received %s; want %s", got, want) - } - }) - } - for name, s := range map[string]any{"input_schema": list.Tools[0].InputSchema, "output_schema": list.Tools[0].OutputSchema} { - constraints := s.(map[string]any)["properties"].(map[string]any)["id"].(map[string]any) - for _, keyword := range []string{"const", "minimum", "maximum"} { - check(name+"_"+keyword, constraints[keyword], literal) - } - check(name+"_enum", constraints["enum"], "["+literal+"]") - } - for name, m := range map[string]Meta{ - "tool_meta": list.Tools[0].Meta, - "result_meta": result.Meta, - "text_meta": result.Content[0].(*TextContent).Meta, - "link_meta": result.Content[1].(*ResourceLink).Meta, - "embedded_meta": result.Content[2].(*EmbeddedResource).Meta, - "resource_meta": result.Content[2].(*EmbeddedResource).Resource.Meta, - } { - check(name, m["test/value"], literal) - check(name+"_nested", m["test/nested"], fmt.Sprintf(`{"values":[%s]}`, literal)) - } - check("structured_content_control", result.StructuredContent, string(payload)) - }) - } - }) - } -} - -func TestToolNumberPrecision(t *testing.T) { - const data = `{"name":"exact","inputSchema":{"type":"object","properties":{"id":{"const":1e400}}},"_meta":{"revision":9007199254740993}}` - var tool Tool - if err := json.Unmarshal([]byte(data), &tool); err != nil { - t.Fatal(err) - } - got := tool.InputSchema.(map[string]any)["properties"].(map[string]any)["id"].(map[string]any)["const"] - if got != json.Number("1e400") { - t.Errorf("const = %v (%T), want json.Number(1e400)", got, got) - } - if got := tool.Meta["revision"]; got != json.Number("9007199254740993") { - t.Errorf("revision = %v (%T), want exact json.Number", got, got) - } - if tool.Name != "exact" || tool.OutputSchema != nil { - t.Errorf("unexpected tool fields: %+v", tool) - } -} diff --git a/mcp/protocol_numbers_test.go b/mcp/protocol_numbers_test.go new file mode 100644 index 000000000..e7cca8521 --- /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+`}`) + }) + } +}