Skip to content

mcp: preserve integers above 2^53 through applySchema - #1302

Open
yehia-khalil wants to merge 1 commit into
modelcontextprotocol:mainfrom
yehia-khalil:fix/integer-fidelity-in-applyschema
Open

yehia-khalil wants to merge 1 commit into
modelcontextprotocol:mainfrom
yehia-khalil:fix/integer-fidelity-in-applyschema

Conversation

@yehia-khalil

Copy link
Copy Markdown

What happens

An integer that is exact in JSON and exact in int64 — but not in float64 — arrives correct and leaves wrong, silently:

in:  {"id":9007199254740993}
out: {"id":9007199254740992}

Off by one, with nothing reporting a problem. Snowflake ids, Discord/Twitter-style ids, and any BIGINT primary key allocated above 2^53 are all in range.

Where

applySchema decodes into any and re-marshals whenever defaults may have been applied:

  • mcp/tool.go unmarshals via internaljson.Unmarshal(data, &unmarshaled), and internal/json's decoder is built with DontMatchCaseInsensitiveStructFields() and no UseNumber() — so every JSON number becomes a float64.
  • The root of a tool result is an object, so appliedDefaults is set true and the function always re-marshals for object-rooted schemas. Its own comment says "Re-marshal only when defaults may have changed the value", but in practice that is every call.
  • The re-marshal then writes back the value that has already lost precision.

Both call sites are affected, so this hits tool arguments (server.go:360) as well as tool results (server.go:422).

Why UseNumber() alone is not the fix

I tried that first. json.Number is a Go string type, and jsonschema rejects it:

validating /properties/id: type: 9007199254740993 has type "string", want "integer"

So the decoded tree has to be narrowed before validation: json.Number → int64 when it is an integer that fits, float64 otherwise. Both validate correctly against "integer" and "number", and both re-marshal to the text they were decoded from — which is the property this is about.

The change

  • internal/json: add UnmarshalPreservingNumbers, which is Unmarshal with UseNumber() set. Existing callers are untouched; a caller decoding into a typed struct is unaffected either way, since UseNumber does not change how a number decodes into a concrete numeric field.
  • mcp/tool.go: applySchema uses it and narrows via a new narrowNumbers helper.

Known limit

A JSON integer outside int64's range (beyond ~9.22e18) still falls back to float64 and loses precision. Representing it exactly would need a big.Int, which jsonschema does not accept and which no MCP client expects. This is documented on narrowNumbers. Every 64-bit database key and every Snowflake-style id is inside int64.

Tests

mcp/integerfidelity_test.go covers:

  • the result path and the argument path (both were broken);
  • nesting inside arrays and objects — a database row set is an array of arrays, which is exactly where these ids live;
  • that a float is still a float afterwards, and that 0.1 survives unchanged.

Each fails on v1.7.0 and passes with this change. The full suite is green (13 packages, 0 failures), including the existing TestApplySchema / TestApplySchemaOutput cases.

How it was found

Measured downstream against a shipped binary over a real stdio pipe — sqlite, MySQL 8 and Mongo 7 all returned 9007199254740992 for a stored 9007199254740993, which ruled out driver behaviour and pointed here. Verified again after this change, on the same binary: the bytes on the pipe now read "rows":[[9007199254740993,"Ada"],[9007199254740995,"Grace"]].

modelcontextprotocol#1239 fixed the CLIENT side of this: CallToolResult.StructuredContent is
typed `any`, so a Go client decoded wire numbers into float64. This is the
SERVER side, which that change did not reach — the bytes are already wrong
before they are sent, so every client is affected, including clients that
are not written in Go and never touch CallToolResult at all.

applySchema decodes a tool's arguments and its result into `any` and
re-marshals whenever defaults may have been applied — which is every
object-rooted schema, so in practice always. The decoder had no
UseNumber(), so every JSON number became a float64 and the re-marshal
wrote the lossy value back out.

An integer that is exact in JSON and exact in int64 but not in float64
therefore arrived correct and left wrong, silently:

    in:  {"id":9007199254740993}
    out: {"id":9007199254740992}

Snowflake ids, Discord/Twitter-style ids and any BIGINT primary key
allocated above 2^53 are all in range, on both the argument path
(server.go:360) and the result path (server.go:422).

UseNumber() alone does not fix it: json.Number is a string type, and
jsonschema reports it as `has type "string", want "integer"`. So the
decoded tree is narrowed first — json.Number becomes int64 when it is an
integer that fits and float64 otherwise. Both validate correctly against
"integer" and "number", and both re-marshal to the text they were decoded
from.

This reuses modelcontextprotocol#1239's internal/json.UnmarshalUseNumber rather than adding a
second decoder helper. That function also runs checkMaxDepth, which an
earlier revision of this change did not — reusing it closes that gap as
well as avoiding the duplicate.

A JSON integer outside int64's range still falls back to float64; that is
documented on narrowNumbers. Representing it exactly would need a big.Int,
which jsonschema does not accept.

Tests cover the argument path, the result path, nesting inside arrays and
objects (a database row set is an array of arrays), and that a float is
still a float afterwards.

Verification:
- gofmt -l .
- go build ./...
- go vet ./...
- go test ./...   (14 packages, 0 failures)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant