[BUG] MCP tool results: image content blocks dropped when `structuredContent` is also present
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Summary
When an MCP tool returns a CallToolResult that contains both a content array (with ImageContent blocks) and a structuredContent field, Claude Code silently discards the entire content array. The model never sees the images; only the JSON-stringified structuredContent reaches the conversation.
The MCP spec permits both fields to coexist — structuredContent is the parsed object form, content carries the renderable blocks. Claude.ai desktop and MCP Inspector handle both correctly. Only Claude Code drops content.
Impact
Any MCP server that returns image content alongside structured output is broken in Claude Code. Servers built with the official Go MCP SDK trip this by default: its typed ToolHandlerFor[In, Out] wrapper unconditionally marshals the second return value into res.StructuredContent after the handler runs, with no opt-out hook (go-sdk@v1.5.0/mcp/server.go:340-394). The official Python SDK's structured-output helpers behave similarly. So this is the common path, not an edge case.
What Should Happen?
The model receives image responses, and for the example the text block + the image. It describes a red pixel (or similar), confirming the image was forwarded.
Error Messages/Logs
Steps to Reproduce
Reproduction
A self-contained ~30-line Python stdio MCP server that emits both fields. No dependencies.
#!/usr/bin/env python3
# repro.py — minimal MCP server returning both content and structuredContent
import json, sys
RED_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
def respond(rid, result):
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}) + "\n")
sys.stdout.flush()
for line in sys.stdin:
line = line.strip()
if not line:
continue
req = json.loads(line)
method, rid = req.get("method"), req.get("id")
if rid is None: # notification, no response
continue
if method == "initialize":
respond(rid, {
"protocolVersion": "2025-06-18",
"capabilities": {"tools": {}},
"serverInfo": {"name": "image-bug-repro", "version": "0.1"},
})
elif method == "tools/list":
respond(rid, {"tools": [{
"name": "get_image",
"description": "Returns text+image content AND structuredContent.",
"inputSchema": {"type": "object", "properties": {}},
}]})
elif method == "tools/call":
respond(rid, {
"content": [
{"type": "text", "text": '{"color":"red","size":"1x1"}'},
{"type": "image", "data": RED_PNG_B64, "mimeType": "image/png"},
],
"structuredContent": {"color": "red", "size": "1x1"},
})
Steps:
- Save as
repro.py. - Register it:
claude mcp add image-bug -- python3 /absolute/path/to/repro.py - Run
claudeand prompt: "Call theget_imagetool and describe what image you see."
Expected
The model receives the text block + the image. It describes a red pixel (or similar), confirming the image was forwarded.
Actual
The model receives only the structured JSON. Asked about the image, it reports there is no image / only metadata.
Cross-check: configure the identical server with Claude.ai desktop or MCP Inspector — both surface the image correctly.
Claude Model
Opus
Is this a regression?
No, this never worked
Last Working Version
_No response_
Claude Code Version
2.1.123
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
iTerm2
Additional Information
Root cause
The bundled-JS function that parses CallToolResult into Claude Code's internal representation short-circuits on structuredContent. Decompiled from claude v2.1.123:
async function lF5(H, _, q, K) {
if (H && typeof H === "object") {
if ("toolResult" in H)
return { content: String(H.toolResult), type: "toolResult" };
if ("structuredContent" in H && H.structuredContent !== void 0)
return {
content: VH(H.structuredContent), // VH = JSON.stringify
type: "structuredContent",
schema: v36(H.structuredContent)
};
// ↑ early-return: H.content (and any ImageContent inside it) is unreachable
if ("content" in H && Array.isArray(H.content)) {
let T = (await Promise.all(H.content.map((A) => cB7(A, q, K, true)))).flat();
return { content: T, type: "contentArray", schema: v36(mD_(T)) };
}
}
...
}
Once structuredContent is present, the H.content branch — where image blocks are processed via cB7 into the model-bound ImageContent form — is never reached. There is no merge, no fallback; image bytes never enter the conversation.
(Function names from minification; the eng with the un-minified source can find this by behavior — it's the function that maps an MCP CallToolResult to the internal { content, type, schema } shape.)
Proposed fix
Swap the precedence: read content first, fall back to structuredContent only when content is absent.
async function lF5(H, _, q, K) {
if (H && typeof H === "object") {
if ("toolResult" in H)
return { content: String(H.toolResult), type: "toolResult" };
if ("content" in H && Array.isArray(H.content)) {
let T = (await Promise.all(H.content.map((A) => cB7(A, q, K, true)))).flat();
return { content: T, type: "contentArray", schema: v36(mD_(T)) };
}
if ("structuredContent" in H && H.structuredContent !== void 0)
return {
content: VH(H.structuredContent),
type: "structuredContent",
schema: v36(H.structuredContent)
};
}
...
}
This matches the MCP spec's intent — the spec recommends mirroring structuredContent into content as text precisely so clients that don't read structuredContent still get the data — and matches Claude.ai desktop's behavior.
A more conservative alternative: keep the current structuredContent path, but also extract image blocks from H.content when present and append them to the result, so the model still receives them. The straight precedence swap above is simpler and lossless.
Test plan
After the fix, run the repro above and verify:
- Claude Code describes the image content from
get_image. - The session transcript contains an
"type":"image"block on the tool result. - Existing tools that return
structuredContentonly (nocontentarray) continue to work — the fallback path covers them.
A regression test would assert that for any MCP CallToolResult containing both content and structuredContent, every block in content (including images) is preserved on the conversation message that gets sent to the model.
Workaround (server side)
Until this lands, server authors can omit structuredContent whenever the result has images. With the Go MCP SDK this requires erasing the typed Out to any at the handler boundary so the SDK's auto-populate step is skipped.
This forces every server author to learn a Claude-Code-specific quirk and trade away outputSchema for image support — it shouldn't be the long-term answer.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗