mcp: preserve integers above 2^53 through applySchema - #1302
Open
yehia-khalil wants to merge 1 commit into
Open
yehia-khalil wants to merge 1 commit into
yehia-khalil wants to merge 1 commit into
Conversation
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)
yehia-khalil
force-pushed
the
fix/integer-fidelity-in-applyschema
branch
from
September 26, 2026 20:53
5fd9a25 to
8305704
Compare
This was referenced Sep 28, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What happens
An integer that is exact in JSON and exact in
int64— but not infloat64— arrives correct and leaves wrong, silently:Off by one, with nothing reporting a problem. Snowflake ids, Discord/Twitter-style ids, and any
BIGINTprimary key allocated above 2^53 are all in range.Where
applySchemadecodes intoanyand re-marshals whenever defaults may have been applied:mcp/tool.gounmarshals viainternaljson.Unmarshal(data, &unmarshaled), andinternal/json's decoder is built withDontMatchCaseInsensitiveStructFields()and noUseNumber()— so every JSON number becomes afloat64.appliedDefaultsis settrueand 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.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 fixI tried that first.
json.Numberis a Go string type, and jsonschema rejects it:So the decoded tree has to be narrowed before validation:
json.Number→int64when it is an integer that fits,float64otherwise. 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: addUnmarshalPreservingNumbers, which isUnmarshalwithUseNumber()set. Existing callers are untouched; a caller decoding into a typed struct is unaffected either way, sinceUseNumberdoes not change how a number decodes into a concrete numeric field.mcp/tool.go:applySchemauses it and narrows via a newnarrowNumbershelper.Known limit
A JSON integer outside
int64's range (beyond ~9.22e18) still falls back tofloat64and loses precision. Representing it exactly would need abig.Int, which jsonschema does not accept and which no MCP client expects. This is documented onnarrowNumbers. Every 64-bit database key and every Snowflake-style id is insideint64.Tests
mcp/integerfidelity_test.gocovers:0.1survives unchanged.Each fails on
v1.7.0and passes with this change. The full suite is green (13 packages, 0 failures), including the existingTestApplySchema/TestApplySchemaOutputcases.How it was found
Measured downstream against a shipped binary over a real stdio pipe —
sqlite, MySQL 8 and Mongo 7 all returned9007199254740992for a stored9007199254740993, 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"]].