[BUG] MCP tool results: image content blocks dropped when `structuredContent` is also present

Resolved 💬 2 comments Opened Apr 29, 2026 by natdempk Closed May 2, 2026

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:

  1. Save as repro.py.
  2. Register it: claude mcp add image-bug -- python3 /absolute/path/to/repro.py
  3. Run claude and prompt: "Call the get_image tool 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:

  1. Claude Code describes the image content from get_image.
  2. The session transcript contains an "type":"image" block on the tool result.
  3. Existing tools that return structuredContent only (no content array) 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.

View original on GitHub ↗

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