Repository navigation
gateway: compare a mirrored header against the body as text, not by re-parsing it - #19
Merged
Merged
Conversation
…e-parsing it
SPEC-v0.2 section 6.4 has the gateway validate every mirrored MCP header against
the body, because "different components in the network rely on different sources of
truth (e.g., a load balancer routing on the header value while the MCP server
executes based on the body value)". The comparison did that by running the header
through int(), and Python's int() is lenient.
So a body carrying amount: 2000 was certified as agreeing with headers spelling it
2_000, +2000, 02000, " 2000", "2000 ", "2000\n", and the Arabic-indic and fullwidth
digit forms; booleans were matched case-insensitively, so TRUE, True and tRuE all
agreed with true. None of those is what a JSON serializer writes, and each is read
differently, or refused, by another parser: parseInt("2_000") is 2 in JavaScript
and strconv.Atoi("2_000") is an error in Go. A gateway that calls that agreement is
telling a load balancer it is safe to route on a header they read as a different
number, which is the hazard the section exists to prevent rather than a
convenience.
CTRLRun's own decisions were never affected. The action is built from the body and
the headers are only checked, so the action hash, the approval binding and the
effect key were always right. What was wrong is the certificate the gateway hands
to everything upstream of it.
A header value must now be the body value's canonical rendering, compared as text:
a string's own characters, and for everything else exactly what JSON writes. One
rendering per value, produced by the serializer the body already went through, so
there is no parser in the comparison and therefore no parser to disagree with.
This also declines the revision's SHOULD that a server compare integers numerically,
under which 42.0 equals 42. A float cannot reach the body at all -- v0.1 section
2.3 refuses it and section 6.6 turns it into -41008 -- so the leniency has no
legitimate case here, and taking it would mean accepting a spelling of a number the
body could never hold. Section 6.4 now states both rules and why.
Mutation table, PYTHONDONTWRITEBYTECODE=1 with __pycache__ cleared between runs:
restore int() and the case-insensitive bool -> 11 failed (the 8 integer
spellings and the 3 boolean ones), restored -> 13 passed
remove the str branch, so strings go through json.dumps and gain quotes
-> 2 failed (the existing Mcp-Param-payment_id tests), restored -> 71 passed
Neither guard is subsumed and neither test is a negative test against something the
environment already refuses: the first mutation shows all eleven spellings were
accepted before, and the canonical spellings keep their own passing tests as the
control, so an implementation that refused every integer header would fail them.
Two existing tests were renamed rather than left saying "numerically", and the
docstring claiming that comparing as text "would refuse every well-formed request"
is gone -- comparing "2000" against str(2000) was always going to work, which is
why the parse was never needed.
Reviewing the previous commit against the revision's Value Encoding section turned up the same defect one layer down. Having stopped re-parsing the header, the comparison still fell through to json.dumps for every remaining type -- so a body carrying extra: null agreed with a header spelling it null, and one carrying a list agreed with the compact JSON rendering of that list. The revision defines encodings for exactly three types: a string as-is, an integer as a decimal string, a boolean as lowercase true or false. It permits x-mcp-header on those three alone, and a null parameter omits its header entirely. So there is no conforming header value for a list, a mapping or a null, and comparing against one CTRLRun made up is the same thing the previous commit removed: certifying an agreement that holds under nobody's rules but ours. Anything that is not a string or an int is now False. Mutation: restore the json.dumps fall-through -> 3 failed (null, list, object), restored -> 3 passed. The list and object cases needed the test tightening first -- written with a plain json.dumps they passed against the old code for an incidental reason, the default separator spacing, rather than on the rule. A float is deliberately not in the parametrization: SPEC-v0.2 section 6.6 refuses one in the body with -41008 before this comparison is reached, so a case for it here would be a negative test against something the environment already prevents.
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.
SPEC-v0.2§6.4 has the gateway validate every mirrored MCP header against the body, because"different components in the network rely on different sources of truth (e.g., a load balancer
routing on the header value while the MCP server executes based on the body value)". The
comparison did that by running the header through
int(), and Python'sint()is lenient.The bug
A body carrying
amount: 2000was certified as agreeing with a header spelling it any of:and booleans were matched case-insensitively, so
TRUE,TrueandtRuEall agreed withtrue. None of those is what a JSON serializer writes, and each is read differently — orrefused — by another parser:
parseInt("2_000")is 2 in JavaScript,strconv.Atoi("2_000")is an error in Go,
int("2_000")is 2000 in Python.So the gateway was telling a load balancer that a header the balancer reads as
2is safe toroute on, while CTRLRun executed
2000. That is the hazard §6.4 exists to prevent, not aconvenience.
CTRLRun's own decisions were never affected. The action is built from the body and the
headers are only checked (§6.4: "the headers are checked; the body is believed"), so the action
hash, the approval binding and the effect key were always right. What was wrong is the
certificate the gateway hands to everything upstream of it — this costs an intermediary's
correctness, not an approval binding.
The fix
A header value must be the body value's canonical rendering, compared as text: a string's
own characters, and for everything else exactly what JSON writes —
2000,true,null. Onerendering per value, produced by the serializer the body already went through, so there is no
parser in the comparison and therefore no parser to disagree with.
The old docstring claimed comparing as text "would refuse every well-formed request that
annotated a numeric parameter". It would not:
"2000" == str(2000). The parse was never needed.A second commit closes the same defect one layer down. Having stopped re-parsing, the
comparison still fell through to
json.dumpsfor every remaining type — so a body carryingextra: nullagreed with a header spelling itnull, and one carrying a list agreed with thatlist's compact JSON rendering. The revision permits
x-mcp-headeron integer, string andboolean only, and a
nullparameter omits its header entirely, so no conforming header valueexists for any other type. Comparing against one CTRLRun invented is the same defect wearing a
different hat. Anything that is not a string or an int is now
False.This was found by reviewing the first commit against the spec's own Value Encoding section —
which also confirms the fix is exactly right rather than merely stricter:
This also declines the revision's SHOULD that a server compare integers numerically, under
which
42.0equals42. A float cannot reach the body at all —v0.1 §2.3refuses it andv0.2 §6.6turns it into-41008— so the leniency has no legitimate case here, and taking itwould mean accepting a spelling of a number the body could never hold. §6.4 now states both
rules and the reasoning.
Mutation table
PYTHONDONTWRITEBYTECODE=1,src/**/__pycache__cleared between runs.int(declared)and the case-insensitive boolstrbranch, so strings go throughjson.dumpsand gain quotesMcp-Param-payment_idagreement testsChecked against the four patterns:
(http_status, code) == (400, -32020)rather than merely "aRefusalcame back".demonstrates all eleven spellings were accepted before this change — the thing the tests
forbid would otherwise happen.
2000,true) keep their own passingtests as the control, so an implementation that refused every integer header would fail them
rather than pass the parametrization.
Checks
pytest1284 passed (16 new) ·mypy --strict srcclean ·ruff checkclean ·ruff format --checkclean.Merge order. This branches off
mainand touchesCHANGELOG.md's[Unreleased]section,which #18 also rewrites. Merging this one first leaves #18 with a one-hunk conflict I will
rebase; the reverse order works too.