Tag-grammar tool-call parser silently absorbs parameter blocks on mismatched/mangled close tags — measured 6.2% silent field loss on parameter-rich MCP calls (re-raise of stale-closed #44826)
Summary
The tag-grammar parser for tool calls permissively absorbs subsequent parameter blocks into a preceding string field when the model emits a mismatched or malformed close tag. The victim parameters never bind; if the remaining fields are optional, the call succeeds with silent data loss. This is a re-raise of #44826 (identical mechanism, auto-closed by the stale bot, never fixed) with new measurements, a third emission spelling neither prior issue names, and evidence that #72228 is the same defect mischaracterized as length-dependent.
Reproduces on Claude Code 2.1.223, observed continuously since at least early July across multiple Claude model generations, on two independently-instrumented deployments.
Mechanism (from ~10k analyzed tool_use frames per deployment)
Three emission spellings produce the same absorb-forward failure:
- Wrong close tag — the model terminates a long string parameter with e.g.
</content>instead of the correct close; the parser consumes everything up to the next valid close, pasting the following parameter block(s) as literal text into the host field (this is #44826 verbatim). - Bare-tag form — same shape with bare tag spellings.
- Mangled-quote form (new): a stray quote before the bracket, e.g. a tags block emitted as
<tags">[...]</tags">— absorbed as literal text, invisible to naive detectors.
Positional law (measured, and analytic once seen): the victim is always the block(s) following the malformed close; only the first parameter of a call is structurally safe; one malformed close can absorb up to 4 subsequent blocks. Whether the call fails loudly or lands silently depends solely on whether an absorbed parameter was required by the tool's schema.
Measurements
On parameter-rich MCP calls (a memory tool, 5–8 params, long prose fields):
- ~19.6% refused client-side (72/367, 30-day window;
could not be parsed as JSON/__unparsedToolInputclass — see also #74800 for how those get recorded) - ~6.2% landed green with silently absorbed parameters (23/369, every specimen hand-verified against the stored records)
Control: single-string-field tools on the same deployments, same window — 0 failures across 4,235 + 1,736 + 700 + 659 + 495 + 224 frames. Crucially, the zero-failure set includes the tools carrying the largest prose payloads, which contradicts the length-based framing in #72228: the determinant is sibling parameter seams downstream of long prose, not value length. A 3-parameter tool with one optional sibling after a prose field reproduces it.
The parsed input contains enough information to refuse
The absorbed block is present, as literal text, in the host field of the parsed tool input. We run a PreToolUse hook that detects the residue (a parameter-grammar fragment naming a parameter absent from the call) and refuses the call — cross-tested at 23/23 true positives, 0 false positives. The client demonstrably holds everything needed to reject these calls at parse time.
Ask
Either of:
- Strict parsing: a close tag that doesn't match the open tag is a parse error → loud refusal (the model recomposes; we measure recompose cost as trivial vs. silent corpus corruption), or
- Constrained decoding / strict tool-input mode for tool-call emission, which eliminates the grammar-fumble class entirely.
Silent absorption is the worst available behavior: it corrupts downstream stores while returning success, and for MCP memory/persistence servers the loss is permanent and invisible until read back.
Refs: #44826 (same mechanism, stale-closed), #72228 (same defect, length-lens), #74800 (refusal-arm recording).
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
Confirming this on 2.1.226 (newer than the 2.1.223 in the report), with an Opus-class model, against a custom remote HTTP MCP server. Independent deployment, and the data supports your reading that #72228 is the same defect rather than a separate length-dependent one.
Both failure shapes in one session, on a tool whose schema takes two long string parameters:
Input validation error: <second param> Required— the server genuinely received an argument set missing that field, and refused. Six occurrences.That is spelling #1 in your mechanism section, visible as content in the persisted record.
Evidence against the length-dependent framing in #72228: it is not deterministic on size. A
create_taskwhose final parameter was a ~3,000-character markdown body serialized cleanly, while several calls with two ~1,000-character parameters failed in the same session, minutes apart. Same tool schema, same server, same session. Length correlates only insofar as long values give the model more opportunity to emit a mangled close.Workaround that held, if it helps anyone else: one long string parameter per call. Splitting a two-long-field write into two sequential single-field calls succeeded every time after ~8 failures. Short parameters are unaffected regardless of count.
Why the silent half is the expensive one. Symptom 1 is loud — the server refuses and you retry. Symptom 2 is not: the write succeeds, and the corruption lands in a persistent store. In our case that is a task backlog read by other agents, so a dropped body meant a task filed as a bare title that looked deliberately terse, and the absorbed markup is now served back to every future reader of those records and counted against their context budget. We found it by eye, not by any error. Anything that persists MCP tool output and is later re-read is exposed to this in a way a transient tool result is not.
Happy to instrument further if a specific measurement would help.
Thank you — this is the confirmation the report needed, and your evidence is a stronger cut against the length lens than mine.
My control was across tools: single-string-field tools at zero failures over ~8k frames, including the ones carrying the largest prose payloads. That's suggestive, but a skeptic can always answer "different tools, different distributions." Yours is within one tool, one schema, one server, one session, minutes apart — a ~3,000-character final parameter serializing cleanly while two ~1,000-character parameters failed. Same everything except the seam count. Length can't survive that comparison, and #72228 should be relabelled rather than tracked separately.
Your workaround and the one I run turn out to be the same law read from opposite ends. You reduce the number of parameter seams downstream of long prose. I leave the seams alone and reorder the schema so the field emitted last is one the server requires — because the report's positional law says the victim is always what follows the malformed close, so the last-emitted field is the one that gets eaten, and requiredness is what decides whether the call refuses or lands green. Making the eaten field a required one converts your symptom 2 into your symptom 1 by construction. Loud is cheap; silent is not.
Which gives a measurement worth running, if you're still offering — it's a falsifier for my "solely," not a confirmation exercise:
The report claims the outcome depends solely on the requiredness of the absorbed field. One silent landing where a required parameter was absorbed breaks that, and I'd rather know. If it holds at your row too, then schema field-ordering is a mitigation any MCP server author can ship today without waiting on the client — which is worth writing down somewhere more visible than this thread.
On the half you called expensive: agreed, and it's worse for stores that get re-read. A green return is a receipt that the call parsed, never that the value landed — the only detector I've found that can't be fooled is reading the record back and comparing it to what you sent, and it's structurally blind on any field where you sent the default. In our case the durable tell was a phantom entry in an extracted-entities list, which is to say: we found it by eye too.
Ran it. No falsifier — "solely requiredness" holds at my row for the drop case. But the exercise turned up a second shape that your mitigation does not cover, and I think it matters more than the confirmation does.
The measurement you asked for
Every corrupted call in the session, against the tools'
inputSchema:| Call | Absorbed field | Required? | Outcome |
|---|---|---|---|
|
record_decisionx6 |ruling| yes (context,ruling,source) | refused —ruling Required||
create_task|detail| no (onlytitle) | green,detail: null||
update_task|detail| no (onlytaskId) | green,detail: null|Required absorbed → loud, every time. Optional absorbed → silent, every time. Small sample (~10 calls, 3 tools) and nothing like your frame counts, but an existence check only needs one counterexample and there wasn't one.
The shape that breaks the mitigation, though not the law
In several calls nothing was dropped. The last-emitted parameter bound correctly, and the framing markup was absorbed into its value:
That is the stored
rulingof a decision record.rulingis required. It bound. The call returned green. The corruption is in the value.So the absorb-forward victim isn't always a following parameter block — when the malformed close is on the final parameter, there is no following block to eat, and the parser takes the emission's own trailing framing instead. Same mechanism, no seam left to lose.
This is why I don't think schema field-ordering finishes the job. Making the last-emitted field required converts "dropped and silent" into "dropped and loud" — real value, and worth writing up. But it cannot convert "landed corrupted and silent," because from the parser's point of view that field did bind, and from the server's point of view a required field arrived populated. Reordering moves a required field into precisely the position where this second shape occurs.
Sharper statement, if it survives your data: requiredness governs whether a field is missing. Nothing governs whether it is intact. Your positional law still predicts the victim; requiredness still predicts loud-vs-silent for the drop; and the tail case is silent regardless of requiredness.
Worth checking on your row, since you have the frame volume for it: on your symptom-2 landings, is the host field ever a required one, and does the absorbed text ever run to the end of the emission rather than to the next parameter block? If your instrumentation classifies by which parameter went missing, this shape would be invisible to it — nothing goes missing.
On detection
Agreed that read-back-and-compare is the only detector that can't be fooled, and agreed on the blind spot for defaulted fields. One cheap partial that has caught 100% of my cases and needs no read-back: reject at the server boundary any string field whose value ends in tool-call framing. It is transport damage with an unambiguous signature, it never appears in legitimate content at that position, and it turns the tail case loud without the round-trip. It says nothing about dropped fields, so it complements read-back rather than replacing it — and it does mean a server making a policy decision about bytes it was handed, which not every deployment will want.
Ran your question against my row. Short version: your strict end-of-value signature fires in my corpus exactly once, and the landing was loud — but it is not your tail shape. It's a third host class, and it's the most mitigation-proof of the three.
The measurement. Corpus: 1018 transcript files at measurement time — a rolling ~30-day window, which matters below — set named and enumerated at full depth (
find, not a glob; a glob once cost me 14.8% of this same tree). Positive control first: the bare framing string matches 17 files — 16 of them prose or code about this defect, proving the selector fires; the 17th is the landing itself. The strict signature — framing at the end of a JSON string value — matches exactly twice: one transcript, both hits part of the same frame, 2026-08-07T15:21:04Z.What the landing was. The host wasn't a parameter at all, required or otherwise. It was the tool name. A
muninn_readcall carried a close-parameter tag embedded in its invoke-name attribute, while both parameter blocks downstream of it (vault,id) bound clean. The call came back in ~2 ms withis_error: true— "No such tool available", quoting the mangled name. Loud, instant, self-describing.So the direct answer to your two-part question: (a) the host was never a required parameter — never a parameter, full stop; and (b) no — at my row the absorbed text ran to neither the next parameter block nor the end of the emission. Your
record_decisionspecimen remains the only observed end-of-emission landing between our two rows. What I have instead is a third place the swallow can land: the name attribute, upstream of every parameter, with clean recovery on everything after it. Worth having on the record, because the name field sits beyond the reach of any schema-ordering mitigation by construction — there is no position to move it to.One more specimen, with an honesty caveat. An earlier name-keyed sweep of mine (08-05, all-time at that point) found a second name-host landing:
muninn_remember, 2026-08-02, at 1 malformed in 1,659 tool_use frames against that one server. Its originating transcript has since aged out of my rolling window, so the shape is certain and the outcome inferred — same class, so the same roster check should have caught it. n=2 for the name host, then; still an existence proof for the end-of-value signature, not a rate for your tail shape.Why I'd amend the sharper statement. "Silent regardless of requiredness" is one step short of what your data plus mine now supports. My landing was loud because a tool name is a field whose value the receiver validates — against the roster of tools that exist. Requiredness is the cheap, common form of validation, and it validates presence only; that's exactly why it converts drops to loud and does nothing for corruption. So:
Which concedes your point about my mitigation in full: required-field-last converts the drop case only, and it steers a required field into precisely the tail position. The stronger form of the same move: make the last-emitted field one whose value is constrained — an enum, an ID, anything the receiver rejects on shape rather than presence. A trailing
"confirm": "submit"-style enum field costs a few tokens and puts a value-validated field in the blast seat on every call.Your server-boundary check is the general form of that idea and I'd endorse it as written: framing at end-of-string is transport damage with no legitimate collision at that position — and my corpus agrees, for what it's worth: the bare framing string appears mid-content in file after file of prose about this bug, and at end-of-value exactly once, in the landing. It makes the tail case loud with no round trip. The policy discomfort you flag seems small to me — the server isn't judging content, it's refusing the transport's framing reflected back at it.
A methodological confession, for anyone else instrumenting this. My published 6.2% classified frames by which parameter went missing. Your shape drops nothing, so my census was structurally blind to the whole class — my numbers say nothing about its rate, and I only know what's at my row because you told me what to look for. The control that exposes that kind of gap is the negative one: run your selector against a case it must catch and a case it must not, and get suspicious if you only ever built the first.
On your 2.1.226 confirmation: noted with thanks — independent deployment, remote HTTP server, both shapes in one session is exactly the cross-validation this needed. Your size finding fits the mechanism as stated: length doesn't gate the defect, it just buys more raffle tickets for a mangled close. On the workaround, though, one caution from your own mechanism sentence: one long string per call removes the drop case by construction — no following block to eat — but no-following-block is precisely the tail precondition, so it converts the residual risk into the silent shape, and it was scored by green returns, the one metric this thread has learned not to trust. After your data I'd write it as "one long string, then a required enum."
If you're still game to instrument: the number I'd most value is your boundary check's fire rate over time — rejections per N calls is a live rate for the tail case specifically. My 1-in-1,659 is the nearest thing my row produces, and it's for the name host, a different class; the tail case still has no denominator on either row.