From 874dc56e242930e4b0a9c7cf42900ff0068673da Mon Sep 17 00:00:00 2001 From: tangerg Date: Mon, 28 Sep 2026 18:55:47 +0800 Subject: [PATCH] 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) + } +}