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:
parent
fbd36cb787
commit
3785c500b5
7 changed files with 124 additions and 7 deletions
|
|
@ -18,6 +18,16 @@
|
|||
|
||||
## `MCPGODEBUG` history
|
||||
|
||||
### 1.9.0
|
||||
|
||||
Options listed below were added and will be removed in the 1.11.0 version of the SDK.
|
||||
|
||||
- `structuredcontentfloat64` added. If set to `1`, numbers in
|
||||
`CallToolResult.StructuredContent` are decoded as `float64`, restoring the
|
||||
previous behavior. The default behavior decodes numbers as `json.Number` so
|
||||
that large integers and other exact JSON values do not silently lose
|
||||
precision.
|
||||
|
||||
### 1.8.0
|
||||
|
||||
Options listed below were added and will be removed in the 1.9.0 version of the SDK.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@
|
|||
|
||||
## `MCPGODEBUG` history
|
||||
|
||||
### 1.9.0
|
||||
|
||||
Options listed below were added and will be removed in the 1.11.0 version of the SDK.
|
||||
|
||||
- `structuredcontentfloat64` added. If set to `1`, numbers in
|
||||
`CallToolResult.StructuredContent` are decoded as `float64`, restoring the
|
||||
previous behavior. The default behavior decodes numbers as `json.Number` so
|
||||
that large integers and other exact JSON values do not silently lose
|
||||
precision.
|
||||
|
||||
### 1.8.0
|
||||
|
||||
Options listed below were added and will be removed in the 1.9.0 version of the SDK.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ func Unmarshal(data []byte, v any) error {
|
|||
return NewDecoder(bytes.NewReader(data)).Decode(v)
|
||||
}
|
||||
|
||||
func UnmarshalUseNumber(data []byte, v any) error {
|
||||
if err := checkMaxDepth(data, defaultMaxDepth); err != nil {
|
||||
return err
|
||||
}
|
||||
dec := NewDecoder(bytes.NewReader(data))
|
||||
dec.dec.UseNumber()
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
// checkMaxDepth scans data once and reports [errMaxDepthExceeded] if the
|
||||
// nesting of JSON objects and arrays exceeds maxDepth. It is a lightweight pass which
|
||||
// tracks '{' and '[' against '}' and ']' while skipping over the contents of strings.
|
||||
|
|
|
|||
|
|
@ -289,6 +289,8 @@ type CallToolResult struct {
|
|||
// result of the tool call. Per SEP-2106, it may marshal to any valid JSON
|
||||
// value (object, array, or primitive) conforming to the tool's
|
||||
// [Tool.OutputSchema].
|
||||
// Numbers received from the wire are represented as [json.Number] to preserve
|
||||
// their exact values.
|
||||
//
|
||||
// When using a [ToolHandlerFor] with structured output, you should not
|
||||
// populate this field. It will be automatically populated with the typed Out
|
||||
|
|
@ -379,6 +381,12 @@ func (r *CallToolResult) hasContent() bool {
|
|||
// An empty InputRequests with NeedsInput true indicates load-shedding.
|
||||
func (r *CallToolResult) NeedsInput() bool { return r.resultType == resultTypeInputRequired }
|
||||
|
||||
// structuredcontentfloat64 is a compatibility parameter that restores the
|
||||
// previous behavior of decoding numbers in [CallToolResult.StructuredContent]
|
||||
// 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 structuredcontentfloat64 = mcpgodebug.Value("structuredcontentfloat64")
|
||||
|
||||
func (x *CallToolResult) MarshalJSON() ([]byte, error) {
|
||||
type res CallToolResult // avoid recursion
|
||||
type wire struct {
|
||||
|
|
@ -401,12 +409,22 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error {
|
|||
type res CallToolResult // avoid recursion
|
||||
var wire struct {
|
||||
res
|
||||
Content []*wireContent `json:"content"`
|
||||
ResultType resultType `json:"resultType"`
|
||||
Content []*wireContent `json:"content"`
|
||||
StructuredContent json.RawMessage `json:"structuredContent"`
|
||||
ResultType resultType `json:"resultType"`
|
||||
}
|
||||
if err := internaljson.Unmarshal(data, &wire); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(wire.StructuredContent) > 0 {
|
||||
unmarshal := internaljson.UnmarshalUseNumber
|
||||
if structuredcontentfloat64 == "1" {
|
||||
unmarshal = internaljson.Unmarshal
|
||||
}
|
||||
if err := unmarshal(wire.StructuredContent, &wire.res.StructuredContent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var err error
|
||||
if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -1285,7 +1285,7 @@ func TestContentUnmarshal(t *testing.T) {
|
|||
Meta: Meta{"m": true},
|
||||
Content: content,
|
||||
IsError: true,
|
||||
StructuredContent: 3.0,
|
||||
StructuredContent: json.Number("3"),
|
||||
}
|
||||
var gotf CallToolResult
|
||||
roundtrip(ctrf, &gotf)
|
||||
|
|
@ -1298,6 +1298,24 @@ func TestContentUnmarshal(t *testing.T) {
|
|||
roundtrip(pm, &gotpm)
|
||||
}
|
||||
|
||||
func TestCallToolResultStructuredContentFloat64Compatibility(t *testing.T) {
|
||||
prev := structuredcontentfloat64
|
||||
structuredcontentfloat64 = "1"
|
||||
t.Cleanup(func() { structuredcontentfloat64 = prev })
|
||||
|
||||
var got CallToolResult
|
||||
if err := json.Unmarshal([]byte(`{"content":[],"structuredContent":{"id":9007199254740993}}`), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
structured, ok := got.StructuredContent.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("StructuredContent type = %T, want map[string]any", got.StructuredContent)
|
||||
}
|
||||
if got := structured["id"]; got != float64(9007199254740992) {
|
||||
t.Errorf("id = %v (%T), want rounded float64 value", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolAnnotations_MarshalJSON(t *testing.T) {
|
||||
boolPtr := func(b bool) *bool { return &b }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -288,8 +288,14 @@ func TestStreamableConcurrentHandling(t *testing.T) {
|
|||
t.Errorf("CallTool failed: %v", err)
|
||||
return
|
||||
}
|
||||
if got := int(res.StructuredContent.(map[string]any)["Count"].(float64)); got != i {
|
||||
t.Errorf("got count %d, want %d", got, i)
|
||||
number, ok := res.StructuredContent.(map[string]any)["Count"].(json.Number)
|
||||
if !ok {
|
||||
t.Errorf("count type = %T, want json.Number", res.StructuredContent.(map[string]any)["Count"])
|
||||
continue
|
||||
}
|
||||
got, err := number.Int64()
|
||||
if err != nil || got != int64(i) {
|
||||
t.Errorf("got count %d, %v; want %d", got, err, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue