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

Open 💬 4 comments Opened Jun 29, 2026 by tblitz

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 ↗

This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗