mcp: preserve structured content number precision (#1239)

CallToolResult currently decodes structuredContent through an any field,
which converts JSON numbers to float64 and silently rounds integers
outside the IEEE-754 safe range.

This change decodes structuredContent separately with UseNumber,
preserving wire numbers as json.Number while retaining the existing
decoded object, array, and primitive shapes. The field documentation now
describes that representation. Users who depend on the previous float64
behavior can temporarily restore it with
MCPGODEBUG=structuredcontentfloat64=1.

An end-to-end in-memory client/server regression covers 9007199254740993
from a low-level Server.AddTool result. Additional coverage verifies the
compatibility option restores the legacy rounded float64 value.

Verification:
- gofmt -l .
- go test ./...
- go vet ./...

Fixes #1236

---------

Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
This commit is contained in:
Luca Candela 2026-09-16 00:22:16 -07:00 • committed by GitHub
commit 3785c500b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -677,7 +677,7 @@ func TestAddToolNonObjectOutputSchema(t *testing.T) {
name: "primitive number (map-based schema)",
outputSchema: map[string]any{"type": "number"},
content: 42.0,
want: 42.0,
want: json.Number("42"),
},
{
name: "primitive string (RawMessage schema)",
@ -724,6 +724,52 @@ func TestAddToolNonObjectOutputSchema(t *testing.T) {
}
}
func TestCallToolStructuredContentPreservesLargeInteger(t *testing.T) {
const want int64 = 9007199254740993
server := NewServer(testImpl, nil)
server.AddTool(&Tool{
Name: "large-integer",
InputSchema: &jsonschema.Schema{Type: "object"},
}, func(context.Context, *CallToolRequest) (*CallToolResult, error) {
return &CallToolResult{StructuredContent: map[string]any{"id": want}}, nil
})
clientTransport, serverTransport := NewInMemoryTransports()
serverSession, err := server.Connect(context.Background(), serverTransport, nil)
if err != nil {
t.Fatal(err)
}
defer serverSession.Close()
client := NewClient(testImpl, nil)
clientSession, err := client.Connect(context.Background(), clientTransport, nil)
if err != nil {
t.Fatal(err)
}
defer clientSession.Close()
result, err := clientSession.CallTool(context.Background(), &CallToolParams{Name: "large-integer"})
if err != nil {
t.Fatal(err)
}
structured, ok := result.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("StructuredContent type = %T, want map[string]any", result.StructuredContent)
}
number, ok := structured["id"].(json.Number)
if !ok {
t.Fatalf("id type = %T, want json.Number", structured["id"])
}
got, err := number.Int64()
if err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("id = %d, want %d", got, want)
}
}
// TestAddToolGenericNonObjectOutput verifies SEP-2106 for the generic
// AddTool[In, Out] helper: Out may be a slice or primitive whose inferred
// JSON Schema has a non-object root.
@ -816,7 +862,7 @@ func TestAddToolGenericNonObjectOutput(t *testing.T) {
if res.IsError {
t.Fatalf("unexpected tool error: %v", res.Content)
}
if diff := cmp.Diff(float64(42), res.StructuredContent); diff != "" {
if diff := cmp.Diff(json.Number("42"), res.StructuredContent); diff != "" {
t.Errorf("structured content mismatch (-want +got):\n%s", diff)
}
})