MCP parameter parser silently swallows args on mismatched close tag
MCP tool calls: parameter parser silently swallows structured args into preceding string field on mismatched close tag
Summary
When Claude emits an <invoke> block for an MCP tool with a multi-paragraph string parameter, it occasionally writes </content> (or another wrong close tag) instead of </parameter> to terminate the parameter. The harness's parser does not error on the mismatch — it permissively consumes everything up to the next valid </parameter> (or </invoke>), silently absorbing all subsequent structured arguments into the value of the first string parameter. The intended structured args arrive at the MCP server as None, while their values appear as literal XML text inside the first parameter's string. The user typically does not notice for days because the call returns success.
Environment
- Claude Code: 2.1.92
- Platform: macOS (darwin 25.4.0)
- Model: claude-opus-4-6 (and observed with sonnet-4-6)
- MCP server: a personal Python FastMCP server, but the corruption is in the JSON-RPC payload the server receives — i.e. before any server-side code runs. Other MCP servers with similar tool signatures are vulnerable.
Observed corruption shape
A remember(content, reason, evidence) tool call should produce JSON-RPC params like:
{
"content": "PLANNER LLM TOO LITERAL: ...",
"reason": "Live test 2026-04-07: ...",
"evidence": "d-029,d-030"
}
Instead, the server receives:
{
"content": "PLANNER LLM TOO LITERAL: ...</content>\n<parameter name=\"reason\">Live test 2026-04-07: ...</parameter>\n<parameter name=\"evidence\">d-029,d-030</parameter>\n</invoke>",
"reason": null,
"evidence": null
}
Note: reason and evidence arrive as None while their literal <parameter> blocks are embedded inside content as text. This is consistent with the parser slurping past a mismatched close tag.
Hypothesis (root cause)
The model emits something like:
<invoke name="mcp__jarvis__remember">
<parameter name="content">PLANNER LLM TOO LITERAL: ...</content>
<parameter name="reason">Live test ...</parameter>
<parameter name="evidence">d-029,d-030</parameter>
</invoke>
Note the </content> where </parameter> was expected. The parser does not error on this mismatch. It treats it as part of the content value and keeps reading until the next </parameter> it can match — which is the close of the reason block, by which point all of reason's value plus the surrounding <parameter name="reason"> and <parameter name="evidence"> markup has been absorbed into content.
Why does the model emit the wrong close tag? Speculation: when the parameter is named content, the close tag </content> is a strong autoregressive prior (it completes a "natural" XML pair). The model knows it just opened <parameter name="content"> but the closing token is biased toward matching the inner attribute value rather than the outer element name. This is consistent with the failure being parameter-name-correlated (we have not observed it on parameters named summary or text, but our sample size is too small to claim that conclusively).
Frequency
In ~2 weeks of normal use across multiple projects, we observed 8 corrupted entries (out of probably ~150 remember() calls — rough order ~5%). All 8 had the same shape; all 8 named the affected parameter content. Two of the 8 had only the trailing </content>\n</invoke> shape (mild — no other args were swallowed because the call had no other string args). Six had structured reason and/or evidence fields swallowed (severe — silent data loss).
Concrete examples
I have all 8 corrupted entries available; here are two representative ones, lightly redacted:
Example 1 — severe (reason + evidence both swallowed)
Tool call: mcp__jarvis__remember(content=..., reason=..., evidence=...)
Server received content:
PLANNER LLM TOO LITERAL: gpt-4.1-nano on the relay planner path echoes user instructions verbatim instead of producing the actual payload Claude should receive. [...] Then if doctrine alone doesn't fix, upgrade model.</content>
<parameter name="reason">Live test 2026-04-07 evening: pivot to multi-session orchestrator works (bounty + converge spawn correctly after the should_relay guard fix), but the relay payload reformulation is broken when user gives meta-instructions. This is the next-most-important UX bug after the routing bugs that landed today.</parameter>
<parameter name="evidence">d-029,d-030</parameter>
</invoke>
<invoke name="mcp__jarvis__write_log">
<parameter name="project">tavi
Note the second <invoke> block at the end — the parser managed to consume past two tool calls' worth of markup before terminating.
Server received reason: null
Server received evidence: null
Example 2 — mild (no other string args, just trailing tags)
Tool call: mcp__jarvis__remember(content=...)
Server received content:
CEMU TEXTURE OVERRIDE FLOW (verified 2026-04-07 from /home/deck/Cemu/src/Cafe/HW/Latte/Core/LatteTexture.cpp:1240-1308 [...]). [...] Constants $width/$height/$gameWidth/$gameHeight/$aspectRatioWidth/$aspectRatioHeight come from the active preset selection in settings.xml under the GraphicPack Entry for that pack.</content>
</invoke>
Just a trailing </content>\n</invoke> — no swallowed args because the call had no other string parameters.
Why this is bad
- Silent data loss. The user has no signal that anything went wrong. The tool call returns success. The corruption is only discovered later (in our case, days, when running a lint pass against the storage).
- Type system gives wrong impression of safety. The MCP tool schema declares
content: str, reason: str, evidence: str. A consumer reasonably assumes that if the call succeeds, all three fields contain what the model intended. They don't. - No recovery path from the server side without heuristics. A defensive server can detect this (we built such a check) but it cannot recover the swallowed args — they're encoded inside the content as XML text and parsing them out reliably requires reimplementing the harness's parser.
- Affects every MCP server with a similar signature. This is not specific to our
remember()tool. Any MCP tool with a multi-paragraph string parameter followed by additional structured parameters is vulnerable.
Proposed fix (in priority order)
- Strict mode in the parameter parser: when a
<parameter name="X">...</X>block is parsed, the closing tag MUST be</parameter>exactly. Anything else should produce a hard error visible to the model on its next turn, so it can self-correct. Silent recovery is the actual bug — one mismatched close tag should not be allowed to consume an arbitrary number of subsequent structured arguments. - If strict mode is too aggressive, at minimum: detect the mismatch, log it, and either (a) refuse to dispatch the tool call with a clear error, or (b) attempt a heuristic recovery (treat the mismatched close tag as if it were
</parameter>) and warn the model that the recovery happened. - Consider biasing the model away from the failure mode: if there's a way to escape close tags inside parameter values (e.g.
<![CDATA[...]]>or backslash escaping), document it in the system prompt that wraps tool call generation. We tried adding a CLAUDE.md rule on our side but it does not stick — the failure rate is too low for the model to learn from the rule alone.
What we did locally
We shipped a server-side boundary check that detects transport-grammar tokens (</invoke>, </parameter>, <parameter name=, </content>) in incoming content strings and rejects the call before any side effects, with an actionable error naming the swallowed fields. We also wrote a one-shot script to clean up the 8 known-bad entries, and added a lint check to catch any legacy rot we missed. This contains the damage but does not fix the upstream parser. The boundary check will become a passive safety net once the parser is fixed — we've added a telemetry counter so we can tell when that happens.
Happy to share full repro scripts, the boundary check code, the cleanup script, or all 8 raw corrupted entries if any of that would help.
---
Reported via Claude Code 2.1.92.
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗