[BUG] Streaming partial-JSON parser silently produces empty MCP tool arguments (accumulator shear)

Status Fixed / completed
Reported on v2.1.173
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Jun 12, 2026 · closed Aug 17, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Environment

  • Claude Code version: 2.1.173
  • Platform: Linux (x86_64, Ubuntu 24.04)
  • MCP server: Custom Python FastMCP server (pydantic-validated tools)
  • MCP transport: Streamable HTTP (uvicorn/starlette)

Summary

Claude Code's streaming input_json_delta accumulation pipeline silently produces {} for MCP tool arguments when the accumulated JSON buffer ends inside a string value. This is caused by a bug in the partial JSON parser (ng$ / VH1/bFH/vH1/kH1 pipeline in the bundled Anthropic SDK), not a transport or serialization issue. Once it happens, the corrupted empty args persist for all subsequent calls to that specific tool for the remainder of the session due to per-tool parameter caching in the harness.

This is the root cause behind at least #3966 and #3296.

Root Cause: Accumulator Shear in VH1 String Tokenizer

The streaming input_json_delta handler accumulates partial_json deltas into a __json_buf, then runs it through a 4-stage partial JSON parser:

ng$ = (H) => JSON.parse(kH1(vH1(bFH(VH1(H)))))
  1. VH1 (tokenizer): char-by-char, produces typed tokens
  2. bFH (tail trimmer): strips incomplete trailing tokens
  3. vH1 (bracket closer): appends missing }/]
  4. kH1 (serializer): reassembles tokens to JSON string

The bug is in VH1's string handler. When the accumulated buffer ends inside a JSON string value (the closing " hasn't arrived from the next delta), the tokenizer sets an Y=true flag and silently drops the entire string token:

// VH1 string handler (offset ~228511118 in v2.1.173 bundle)
if (K === '"') {
  let z = "", Y = false;
  K = H[++$];
  while (K !== '"') {
    if ($ === H.length) { Y = true; break; }  // buffer ends mid-string
    // ...
  }
  if (K = H[++$], !Y)
    q.push({type: "string", value: z});  // DROPPED when Y=true
  continue;
}

The cascade: Once a value string is dropped, the tail trimmer (bFH) removes the now-orphaned key token and its separator. For large payloads spanning many deltas, multiple drops cascade until the entire object body is trimmed to {}.

The silent {} path: The {} result flows through JSON.parse("{}"), becomes the content block's input field (already an object, not a string), bypasses the typeof _.input === "string" normalization branch entirely, and reaches Client.callTool({name, arguments: {}}).

Secondary Issue: Silent f = A ?? {} Fallback

When _.input IS a string that fails JSON.parse (a less common path), the normalization code at offset ~239367857 fires a telemetry event (tengu_tool_input_json_parse_fail) but then silently falls back:

if (typeof _.input === "string") {
  let A = A4(_.input, false);  // JSON.parse wrapper
  if (A === null && _.input.length > 0)
    c("tengu_tool_input_json_parse_fail", {...});
  f = A ?? {};  // SILENT FALLBACK TO {}
}

Third Issue: Per-Tool Parameter Cache Makes It Persistent

Once a tool call produces corrupted/empty input (via either path above), the Claude Code harness caches the content block's input field as the tool's parameter template. All subsequent calls to that same tool in the session reuse the cached empty args. Other tools on the same MCP server are unaffected (the cache is per-tool, not per-server).

This explains the behavior reported in #3296 where "once it gets into this state, it does not evaluate the errors returned from the MCP" and "repeats the tool call over and over with no changes to args." Session restart is the only recovery because it clears the cache.

Fourth Issue: Top-Level Backslash Handler

VH1 also has a top-level backslash handler that eats two characters:

if (K === "\") { $++; continue; }

If a streaming boundary produces a spurious top-level \ (from a truncated escape sequence), the next character is consumed without emitting a token. If that character is {, ", or ,, structure is silently lost.

Why This Disproportionately Affects Certain Tools

The partial JSON parser runs on every input_json_delta event on the accumulated buffer. Small payloads have short string values that fit in a single delta. Tools with large string arguments (our append_entry carries multi-KB markdown bodies, but any tool with substantial text input is affected) span many deltas, making it near-certain that at least one parser invocation sees an unterminated string.

Two Triggers

  1. Escape interrupt during in-flight call (deterministic): user pressing Escape while a tool call is streaming leaves the content block's input in a partially accumulated state.
  2. Streaming delta boundary shear (intermittent): the model's token chunking splits a JSON string value across delta boundaries, and the parser runs while the buffer contains an unterminated string.

Both leave the content block's input corrupted; the per-tool cache then makes it persistent.

Evidence

Server-side ASGI instrumentation at the transport boundary logged the events:

  • body_bytes=180 (exact size of {"jsonrpc":"2.0","method":"tools/call","params":{"name":"append_entry","arguments":{}}})
  • disconnect=False (no transport truncation)
  • parse_err=None (valid JSON, just empty arguments)

The client sent well-formed JSON-RPC with arguments: {}. The server received it intact. The fault is entirely client-side.

Reproduction

  1. Set up an MCP server with a tool that accepts a large string argument (>4KB)
  2. Call the tool repeatedly in a session
  3. Eventually the streaming chunking will split the string across a delta boundary, the parser drops the incomplete token, and {} reaches the server
  4. All subsequent calls to that tool in the session send {} (per-tool cache)

Escape-interrupt trigger is more reliable: start a tool call with a large argument, press Escape during streaming, then call the same tool again.

Recommended Fix

  1. VH1 string tokenizer: preserve incomplete (unterminated) string tokens instead of dropping them; emit a provisional token type that bFH can trim cleanly without cascading into the key/delimiter context.
  2. VH1 top-level backslash handler: do not advance past the character following the backslash ($++; continue eats two chars); skip only the backslash itself so structurally significant characters aren't silently consumed.
  3. Input normalization: replace f = A ?? {} with error propagation or retry. The telemetry event fires but the empty object should not reach callTool.
  4. Harness parameter cache: do not cache tool parameter state derived from an aborted or errored content block.

Related Issues

  • #66247 - 8x regression in tool-call parse failures since v2.1.165 (same underlying bug, measured from telemetry side); open
  • #3966 - describes the same symptom (empty {} args reaching MCP servers); closed without root cause
  • #4188 - Claude Desktop MCP tools failing with "Required" parameter error
  • #2089 - Claude Code no longer passing parameters to MCP
  • #3296 - describes the persistent-failure-then-hammering behavior (per-tool cache); closed without root cause

View original on GitHub ↗

4 Comments

github-actions[bot] · 2 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/66247
  2. https://github.com/anthropics/claude-code/issues/3966
  3. https://github.com/anthropics/claude-code/issues/3296

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

in4mer · 2 months ago

Not a duplicate. The three linked issues describe the symptom (empty args, parse failures); this issue provides the root cause with code-level analysis of the bundled JS:

  • The partial JSON parser's VH1 string tokenizer silently drops unterminated string tokens, cascading through the tail trimmer to produce {} (accumulator shear)
  • The f = A ?? {} fallback in input normalization silently swallows parse failures
  • Per-tool parameter caching in the harness makes the corruption persistent for the session
  • A top-level backslash handler eats two characters instead of one

#66247 (open) is the same bug measured from the telemetry side (8x regression in "tool call could not be parsed" errors since v2.1.165). #3966 and #3296 (both closed/locked) are earlier reports of the same symptom without root cause.

All four recommended fixes are in the issue body with specific code locations in the v2.1.173 bundle.

Quad erat demonstrandum

in4mer · 1 month ago

Cross-linking three related reports that appear to touch the same streamed-tool-argument surface, with a note on how confident each linkage is:

#69085 (closed as duplicate) — very likely the same root cause. It reproduces the tail-truncation variant on Windows (this report was Linux/macOS) and, more usefully, adds an MCP-server-independent control: a minimal echo server driven by a spec-correct httpx client round-trips payloads from 256 B to 64 KB intact, while Claude Code's own client intermittently truncates the same size band. That empirically exonerates the server, transport, and SDK and isolates the fault to the client's input_json_delta accumulation, consistent with the VH1 string-token dropout described here. Worth pulling its control-test evidence into this thread even though it is closed.

#70167 — possibly related, different symptom axis. Write tool invoked with arguments: {} (all fields undefined), but the correlating condition there is the deferred-tool / ToolSearch load path specifically (read tools on the same server are unaffected). This could be the same per-tool empty-args behavior surfacing through the deferred-invocation path, or a distinct schema-load defect. Flagging it so it can be checked against the same accumulation code rather than triaged in isolation.

#69522 — possibly related, parse-failure axis. Long, heavily \uXXXX-escaped arguments fail JSON parse, and a shorter retry of the same logical call succeeds. The size correlation and the escape sensitivity line up with the top-level backslash handler eating structurally-significant characters. Cannot confirm from the outside whether it is the same path or an independent serialization issue.

Net: the accumulator-family surface seems to span three symptoms (tail-cut, empty-args, and escape parse-fail). #69085 is the strongest corroboration that the truncation originates client-side before the bytes leave Claude Code.

bcherny collaborator · 14 days ago

Tried to reproduce this on 2.1.233 (Linux): a stdio MCP server with echo_args(text, n) that logs every received arguments payload, run twice with the recipe from this issue — 10 calls per session, each with 280–360-char multi-sentence texts containing nested quotes, emoji, and CJK (so the streamed JSON buffer ends inside a string value many times per call).

Result: 20/20 calls arrived at the server with complete arguments — 0 empty, and all 20 tool_use inputs in the stream-json events were fully populated. We also couldn't find any per-call argument caching — each call's arguments are transported independently, so there's no mechanism for a bad call to poison later ones.

The underlying symptom you saw on 2.1.173 (a tool call arriving with {} when the streamed argument JSON was malformed) was real, and argument handling in this area was reworked in a recent release — malformed input now surfaces as an explicit, retryable error instead of silently becoming {}. See the changelog.

Closing as fixed — if you still see empty arguments on 2.1.233 or later, please reply with your version and a repro (ideally the server-side log of the received payload) and we'll reopen.

🤖 Generated with Claude Code