anthropics / anthropics/claude-agent-sdk-typescript
[BUG] MCP structuredContent bypasses oversized-result truncation → context refill and autocompact-thrashing abort
- Vorherrschende Sprache
- Shell
- Sterne
- 1.8k
- Forks
- 226
- PR-Merge-Kennzahlen
- Keine gemergten PRs in 30 T.
Beschreibung
## Environment
- `@anthropic-ai/claude-agent-sdk` 0.3.220 (latest at time of writing), bundled CLI 2.1.211 (manifest commit `17a4b6d7b2ee1936b95e595054c7e7d38fddafb7`)
- Reproduced on darwin-arm64 (Node 25.2.1) and linux-x64 (containerized headless agent)
- MCP servers: stdio (also reproduced with in-process SDK MCP servers)
## Summary
The SDK's oversized-MCP-result handling (`MAX_MCP_OUTPUT_TOKENS` → persist to `tool-results/` file → replace content with the "Error: result (N characters) exceeds maximum allowed tokens. Output has been saved to …" pointer) only protects the normalized text content. When the MCP server also returns `structuredContent`, the raw object survives the truncation:
1. **Below the limit**, the serialized `structuredContent` *is* the model-facing `tool_result` content (the text content blocks are discarded). No character-based bound applies to it.
2. **Above the limit**, the content is correctly persisted and replaced with the file pointer, but the raw `structuredContent` is re-attached to the tool result and rides along on the emitted message (`tool_use_result` / `mcpMeta`). In our production sessions the context then refills to the limit within 1–3 turns of every compaction.
The end state is a hard session abort:
```
Autocompact is thrashing: the context refilled to the limit within 3 turns of the
previous compact, 3 times in a row. A file being read or a tool output is likely
too large for the context window. Try reading in smaller chunks, or use /clear to
start fresh.
```
## Production impact (how we found it)
Headless incident-response agent (Agent SDK + stdio PagerDuty MCP server). One broad `list_incidents` call returned 296,067 characters. The SDK *did* persist it ("Error: result (296,067 characters) exceeds maximum allowed tokens. Output has been saved to …"), yet the same user message carried the full payload in `tool_use_result.structuredContent`. Session telemetry:
- compact 1: `pre_tokens: 174396 → post_tokens: 20451`
- one `list_incidents` call later, 13 seconds after compact finished: context back at the ceiling
- compact 2: `pre_tokens: 170772 → post_tokens: 14137`
- compact 3: `pre_tokens: 167926 → post_tokens: 22738`
- abort with the thrashing error after 6.4 minutes, 32 turns, $2.47, three compactions of 80–97s each
The post-compact summary also tells the model it was mid-investigation, so it re-issues the same broad call after every compaction, which makes the loop self-sustaining.
## Minimal repro
`probe-server.mjs` — stdio MCP server whose tool returns both `content` and a large `structuredContent`:
```js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "probe", version: "1.0.0" });
server.registerTool(
"get_data",
{
description: "Returns test data. Call this exactly once.",
inputSchema: { topic: z.string() },
outputSchema: { secret: z.string(), filler: z.string() },
},
async () => ({
content: [{ type: "text", text: '{"summary":"ok"}' }],
structuredContent: { secret: "LEAK_CANARY_9f8e7d6c", filler: "x".repeat(120000) },
}),
);
await server.connect(new StdioServerTransport());
```
`repro.mjs` — drive it via the Agent SDK and inspect the API-facing tool_result:
```js
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const msg of query({
prompt: "Call the mcp__probe__get_data tool with topic 'test', then reply DONE. Do nothing else.",
options: {
model: "claude-haiku-4-5",
maxTurns: 4,
allowedTools: ["mcp__probe__get_data"],
permissionMode: "bypassPermissions",
mcpServers: { probe: { command: "node", args: ["probe-server.mjs"], alwaysLoad: true } },
},
})) {
if (msg.type === "user") {
const apiContent = JSON.stringify(msg.message?.content);
console.log("api content length:", apiContent.length);
console.log("canary leaked:", apiContent.includes("LEAK_CANARY_9f8e7d6c"));
}
}
```
**Observed:** `api content length: 120xxx`, `canary leaked: true`. The `tool_result` content sent to the model is the full 120KB serialized `structuredContent`. The tool's text content block (`{"summary":"ok"}`) is discarded.
For contrast, in the same session a **plain-text** tool result of only ~50KB gets the `` treatment (persisted to a file, 2.4KB pointer inlined). 120KB of `structuredContent` sails through while 50KB of text is persisted.
With a payload of dense JSON (real-world API responses, not repeated characters) large enough to trip the token estimate, the content is replaced with the persisted-file pointer, but the full payload is still attached to the emitted user message under `tool_use_result` — that's the production case above.
## Where the bypass happens
From the bundled CLI (extracted from the embedded bundle of `@anthropic-ai/claude-agent-sdk-darwin-arm64/claude`, so names are minified — byte offsets in the 2.1.211 darwin-arm64 binary for reference):
1. Result normalization (~223,958,800) prefers `structuredContent` as the model-facing payload:
```js
if ("structuredContent" in e && e.structuredContent !== void 0) {
let i = De(e.structuredContent), s = Inr(e.structuredContent); // JSON.stringify
...
return { content: i, type: "structuredContent", schema: s };
}
```
2. The size check + persist (~223,959,300) runs on that string — so far so good:
```js
let { content: i, type: s, schema: a } = await wtd(e, t, r, n);
...
if (!await lqi(i)) return i; // under limit → pass through
...
let E = await Gtt(g, u); // over limit → persist, return pointer message
```
3. **The defect** — the caller (offset 223,967,944) re-attaches the raw object after the check already decided the result is oversized:
```js
return { content: await Atd(O, o, t, u, c), _meta: O._meta, structuredContent: O.structuredContent }
```
4. It then propagates into `mcpMeta` (~223,997,480) and onto the emitted message (~221,949,300):
```js
tool_use_result: r.mcpMeta ? { content: r.toolUseResult, ...r.mcpMeta } : r.toolUseResult
```
## Expected behavior
- When the oversized-result path fires and the content is replaced with a persisted-file pointer, `structuredContent` should be dropped (or persisted alongside) rather than re-attached verbatim — nothing about the original payload should keep occupying context.
- When `structuredContent` is used as the model-facing content, it should be subject to the same size limiting as text content.
## Workaround
A `PostToolUse` hook receives the model-facing payload as a string (the serialized `structuredContent` when present) and `updatedToolOutput` replaces it wholesale, so consumers can cap it themselves:
```js
hooks: {
PostToolUse: [{
matcher: "^mcp__",
hooks: [async (input) => {
const r = input.tool_response;
if (typeof r !== "string" || r.length <= 50_000) return { continue: true };
return {
hookSpecificOutput: {
hookEventName: "PostToolUse",
updatedToolOutput: r.slice(0, 50_000) + "\n[Result truncated. Narrow the query instead of retrying.]",
},
};
}],
}],
}
```
This works (verified with the canary repro: the capped string composes correctly with the persisted-output machinery), but every headless Agent SDK deployment with chatty MCP servers hits the thrashing abort until then.
Related but distinct: #15412 reports the "structuredContent displayed while content array ignored" behavior from step 1 as a display issue; this report is about the size-limit bypass and the resulting session-killing compaction loop.
Beitragsleitfaden
Für dieses Repository ist kein Beitragsleitfaden indexiert
Bewertung
Dieses Issue wurde noch nicht bewertet.