Skip to content

gateway: compare a mirrored header against the body as text, not by re-parsing it - #19

Merged
arpanghoshal merged 2 commits into
mainfrom
gateway-header-value-comparison
Sep 4, 2026
Merged

arpanghoshal merged 2 commits into
mainfrom
gateway-header-value-comparison

Conversation

@arpanghoshal

@arpanghoshal arpanghoshal commented Sep 4, 2026 •

Copy link
Copy Markdown
Member

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's int() is lenient.

The bug

A body carrying amount: 2000 was certified as agreeing with a header spelling it any of:

2_000     +2000     02000     " 2000"     "2000 "     "2000\n"     ٢٠٠٠     2000

and 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, 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 2 is safe to
route on, while CTRLRun executed 2000. That is the hazard §6.4 exists to prevent, not a
convenience.

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. 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.

if isinstance(value, str):
    return declared == value
return declared == json.dumps(value, separators=(",", ":"))

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.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 that
list's compact JSON rendering. The revision permits x-mcp-header on integer, string and
boolean only
, and a null parameter omits its header entirely, so no conforming header value
exists 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:

Type conversion: Convert the parameter value to its string representation:
string: Use the value as-is · integer: Convert to decimal string representation (e.g.,
42, -7) · boolean: Convert to lowercase "true" or "false"

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 §2.3 refuses it and
v0.2 §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. §6.4 now states both
rules and the reasoning.

Mutation table

PYTHONDONTWRITEBYTECODE=1, src/**/__pycache__ cleared between runs.

Mutation Result Restored
Restore int(declared) and the case-insensitive bool 11 failed — the 8 integer spellings and the 3 boolean ones 13 passed
Remove the str branch, so strings go through json.dumps and gain quotes 2 failed — the existing Mcp-Param-payment_id agreement tests 71 passed

Checked against the four patterns:

  • Not subsumed. Each mutation fails a distinct set of tests, and each asserts the refusal by
    (http_status, code) == (400, -32020) rather than merely "a Refusal came back".
  • Not a negative test against behaviour the library already refuses. The first mutation
    demonstrates all eleven spellings were accepted before this change — the thing the tests
    forbid would otherwise happen.
  • The observer could see it. The canonical spellings (2000, true) keep their own passing
    tests as the control, so an implementation that refused every integer header would fail them
    rather than pass the parametrization.

Checks

pytest 1284 passed (16 new) · mypy --strict src clean · ruff check clean ·
ruff format --check clean.


Merge order. This branches off main and touches CHANGELOG.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.

…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.
@arpanghoshal
arpanghoshal merged commit 4732cf4 into main Sep 4, 2026
3 checks passed
@arpanghoshal
arpanghoshal deleted the gateway-header-value-comparison branch September 4, 2026 05:10
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