MCP tool calls silently drop parameters emitted after a long parameter value (v2.1.195)

Status Open
Reported on v2.1.195
Maintainer reply None cached
Activity 6 comments · opened Jun 29, 2026

Summary

When Claude Code emits a call to an MCP tool (mcp__<server>__<tool>), a
sufficiently long parameter value causes every parameter emitted after it to
be dropped
before the request leaves the client. The server receives a partial
argument set. If the tool fills the missing fields with defaults
(arguments.get(key, default)), there is no error — the call "succeeds" with
silently wrong data.

Why it matters

We hit this in a memory-capture MCP tool with ~12 parameters. Because its first
field was long, every later field — lesson type, tags, confidence, reasoning —
was dropped and silently replaced with defaults. Records stored looking complete
but weren't; nothing surfaced the loss. We found it only by auditing stored
entries later. Any structured MCP tool with a long-ish field is exposed, and the
failure mode is invisible at write time.

It is client-side (proof)

The identical payload sent to the same server via a direct MCP stdio client
arrives complete. Only calls emitted by Claude Code lose the trailing params.
The server, transport, and tool inputSchema are therefore correct — the
truncation is in Claude Code's mcp__* emission path. Built-in tools
(Edit/Write) are unaffected, even with much larger values.

Reproduce (~2 min, self-contained)

A tiny echo MCP server whose one tool reports which arguments the server
actually received:

#!/usr/bin/env python3
"""Minimal stdio MCP server for reproducing the Claude Code param-drop bug.

Exposes one tool, `echo`, that simply reports WHICH arguments the server
actually received (and their lengths). When Claude Code calls it with a long
value for `a` followed by short `b`/`c`/`d`, the result shows only the params
that survived emission — demonstrating that trailing params were dropped before
the request reached the server.

You do not run this file directly. It is a server that gets launched for you:
  - by Claude Code, after you register it with
    `claude mcp add echo -- python3 echo_mcp_server.py`  (README, Step 1), or
  - by control_client.py, which spawns it as a subprocess  (README, Step 2).
Only dependency: `pip install mcp`.
"""
import asyncio

from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent

server = Server("echo-mcp")


@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="echo",
            description=(
                "Diagnostic: returns which arguments the SERVER actually "
                "received. Call with a long `a` plus short `b`, `c`, `d`."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "a": {"type": "string", "description": "primary — make this LONG"},
                    "b": {"type": "string", "description": "short trailing detector"},
                    "c": {"type": "string", "description": "short trailing detector"},
                    "d": {"type": "string", "description": "short trailing detector"},
                },
                "required": ["a"],
            },
        )
    ]


@server.call_tool()
async def call_tool(name: str, arguments: dict | None) -> list[TextContent]:
    args = arguments or {}
    keys = sorted(args.keys())
    lengths = {k: len(str(v)) for k, v in args.items()}
    missing = [k for k in ("a", "b", "c", "d") if k not in args]
    text = (
        f"keys received by server : {keys}\n"
        f"value lengths           : {lengths}\n"
        f"declared params MISSING : {missing}\n"
        + ("^^ TRAILING PARAMS DROPPED before reaching server\n" if missing else
           "^^ all params arrived\n")
    )
    return [TextContent(type="text", text=text)]


async def main() -> None:
    async with stdio_server() as (read_stream, write_stream):
        await server.run(
            read_stream, write_stream, server.create_initialization_options()
        )


if __name__ == "__main__":
    asyncio.run(main())

A control client that sends the failing payload straight to that server, with no
Claude Code in the loop:

#!/usr/bin/env python3
"""Control: send the EXACT failing payload to echo_mcp_server.py via a direct
MCP stdio client (no Claude Code in the loop). All four params arrive — proving
the server, transport, and schema are correct, and isolating the drop to Claude
Code's tool-call emission path.

Run: python3 control_client.py
Expect: keys received by server : ['a', 'b', 'c', 'd]  (all arrive)
"""
import asyncio
import os
import sys

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

HERE = os.path.dirname(os.path.abspath(__file__))

# A long, punctuation-heavy `a` — the same shape that drops trailing params when
# emitted by Claude Code. Followed by three short detector params.
LONG_A = (
    "Considered Path A (handler ~line 1033) vs Path B (register the hook + "
    "persist 4 fields). Chose A: single registered path already firing; ~580KB "
    "write completes in ~46ms. Note: the refactor (Apr 19) orphaned the call — "
    "moved OUT of one hook INTO an unregistered one. Assumes timeout=1 child "
    "completes async; verified by direct test."
)
PAYLOAD = {"a": LONG_A, "b": "B-short", "c": "C-short", "d": "D-short"}


async def main() -> None:
    params = StdioServerParameters(
        command=sys.executable,
        args=[os.path.join(HERE, "echo_mcp_server.py")],
        env={**os.environ},
    )
    async with stdio_client(params) as (r, w):
        async with ClientSession(r, w) as s:
            await s.initialize()
            res = await s.call_tool("echo", PAYLOAD)
            for c in res.content:
                print(getattr(c, "text", c))
            print(f"(payload sent had len(a)={len(LONG_A)} and keys "
                  f"{sorted(PAYLOAD.keys())})")


if __name__ == "__main__":
    asyncio.run(main())

Steps:

  1. pip install mcp
  2. claude mcp add echo -- python3 "$PWD/echo_mcp_server.py"
  3. Start a new Claude Code session (MCP tools load at session start) and ask

it to call echo with a long, punctuation-heavy a and short b/c/d.
Result: keys received by server: ['a']b/c/d dropped.

  1. Run python3 control_client.py — the same payload, straight to the same

server: keys received by server: ['a', 'b', 'c', 'd']. All four arrive.

Trigger (partially characterized)

Not a clean byte cap — content interacts with length:

  • plain-text values transmitted correctly through ~350 chars in one field, and

~420 chars cumulative across fields;

  • a ~330-char value with heavy punctuation (( ) — = ~, multiple clauses)

dropped the params after it;

  • a ~540-char plain value also dropped them.

This points away from a simple size limit and toward the emission path being
sensitive to value content as well as length. We did not fully bisect it.

Expected

All parameters transmit regardless of any one value's length or content.

Environment

Claude Code 2.1.195 · Linux · stdio MCP servers · Claude Opus 4.8

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/53186
  2. https://github.com/anthropics/claude-code/issues/44826
  3. https://github.com/anthropics/claude-code/issues/36319

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

tblitz · 2 months ago

Consolidating this with the prior reports — and why it still needs an engineer (reproduced on 2.1.195)

After testing, this is the same root cause already documented in #48679 ("100% repro"), #44826, and #53186: the MCP tool-call parser silently absorbs a parameter into the preceding string value when Claude emits a mismatched close tag — a field-named tag like </content> instead of </parameter>. The parser consumes everything up to the next valid </parameter>, the following parameter arrives null, and #895 sees the downstream required parameter X is missing.

Three things those threads don't have together in one place:

1. It is silent data corruption, and it is current. The call returns success with a null/absorbed field — no error at write time. We found it only by auditing stored records afterward. Reproduced on Claude Code 2.1.195, model claude-opus-4-8, Linux.

2. A controlled isolation that pins the layer. The identical payload sent to the same MCP server via a direct stdio client arrives 100% intact; only Claude-Code-emitted calls corrupt. So the fault is the emission/parse path — not the server, schema, or transport. The minimal self-contained repro (echo server + control client) is in the issue body above. Captured shape: a content value ended ...done.</content> + <parameter name="summary">…, with summary lost and a later tags param surviving — i.e. everything up to the next valid </parameter> is absorbed.

3. The triage history, laid out. Every well-documented root-cause report has been closed by automation, not resolved:

  • #48679 (100% repro, area:mcp) → NOT_PLANNED
  • #53186 → auto-deduped into #48679
  • #44826 (has-repro, corroborated by two independent users in-thread) → staleness-closed ("inactive for too long")
  • #895 (the symptom) → still OPEN, 54 comments, reports through 2.1.x

The gap isn't evidence — it's that each clean repro gets deduped or staled before reaching a fix.

The fix is small and well-scoped. Treat only </parameter> as a parameter terminator; on a mismatched/field-named close tag (or an embedded <parameter name=…> inside a value), error or repair instead of silently absorbing. That converts a silent-corruption bug into a loud, recoverable one.

Ask: could this be human-triaged (area:mcp) and the root cause reopened, rather than auto-deduped/staled? Happy to supply additional repros, session JSONL, or a failing-case corpus.

yurukusa · 2 months ago

Until the emission-path fix lands, the highest-leverage move is to make this failure mode loud instead of silent — the danger here isn't the drop, it's that arguments.get(key, default) swallows it and the call still "succeeds." A few operator-side mitigations that don't depend on a client fix:

  1. Validate required params server-side; never default them. If a declared-required field is absent, raise instead of filling a default. That converts a silent wrong-data write into an immediate, visible error at call time — which is exactly when you can still react.
  2. Order params short-first. Since the drop is "everything after a long value," putting the short required fields (type, tags, confidence) before the long free-text field shrinks the blast radius to the long field only.
  3. Don't pack a long value into one argument. Split a long body into summary + full, or a separate call, so one oversized value can't take the rest of the schema down with it.
  4. Audit already-stored records. Anything written through an affected tool before today may have fields silently sitting at defaults — an unusually high rate of default-valued required fields is the tell.

(2)–(3) reduce the odds of hitting it; (1) is the one that matters most, because it removes the silence — the part that let it run undetected until a later audit.

ItsTheCat · 1 month ago

Independently reproduced on a different OS, transport, and version — sharing to widen the surface:

  • Windows 11, Claude Code 1.18286.0, against a Streamable-HTTP MCP server

(modelcontextprotocol/go-sdk v1.6.1) — not stdio. Same symptom: a parameter emitted after a
long one silently vanishes before leaving the client. So it isn't OS-, transport-, or
version-specific — it's in the core mcp__* emission path. (Given that, the platform:linux
label is likely too narrow.)

  • Client-side, confirmed two ways: the identical arguments save correctly when sent to the same

server (a) via its REST API and (b) via a hand-authored raw JSON-RPC tools/call to the same
/mcp endpoint. Only Claude Code's own emission drops the trailing param.

  • The cumulative effect spans a whole turn, not just one call: with several tool calls emitted

in one assistant turn, a call that succeeds in isolation fails when a sibling call in the same
turn carries a long value — so the drop tracks cumulative emitted length across the turn's tool
calls, not only across fields within one call.

  • Concrete case: a long content param reliably dropped the tags param that followed it. Our

receiving column was NOT NULL, so it errored loudly rather than the silent .get(default)
corruption noted above — same root cause, louder failure mode.

Showing cached comments. Read the full discussion on GitHub ↗