[BUG] MCP ImageContent returned as text in tool results instead of native image blocks (10-20x token waste)

Status Closed — not planned
Maintainer reply None cached
Activity 11 comments · opened Mar 5, 2026 · closed May 3, 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?

When an MCP server returns ImageContent (e.g., a matplotlib chart from the Jupyter MCP server), Claude Code does not convert it into a native image content block for the Anthropic API. Instead, the base64 data appears to be treated as text in the tool result, consuming ~15,000-25,000 tokens per image.

The same image, when attached directly as a user message (e.g., pasting a screenshot), is processed as a native image and costs only ~1,600 tokens — roughly 10-20x less.

This makes iterative notebook workflows (where multiple charts are produced) impractical, as a notebook with 10 charts can consume 150K-250K tokens just on image data that Claude can't even interpret (it's just base64 text characters).

What Should Happen?

When an MCP tool returns ImageContent per the MCP spec:

{
  "type": "image",
  "data": "<base64-encoded-data>",
  "mimeType": "image/png"
}

Claude Code should convert this into a native image content block in the API request:

{
  "type": "image",
  "source": {
    "type": "base64",
    "media_type": "image/png",
    "data": "<base64-encoded-data>"
  }
}

This way the model receives the image as an actual image (~1,600 tokens for a typical chart) and can visually interpret it, rather than receiving a wall of base64 text (~20,000 tokens) that it cannot interpret.

Observed Behavior

In the Claude Code terminal, large MCP image results show messages like:

Error: result (62,162 characters) exceeds maximum allowed tokens. Output has been saved to ~/.claude/projects/.../tool-results/mcp-jupyter-execute_cell-XXX.txt

The base64 image data is being saved as a text file and treated as text content. Claude sees the raw base64 characters but cannot interpret them as an image.

Reproduction Steps

  1. Set up the Jupyter MCP server with Claude Code
  2. Connect to a notebook and execute a cell that produces a matplotlib chart
  3. Observe that the result is treated as text (base64 string), not as a native image

Impact

This affects any MCP server that returns images: Jupyter (charts/plots), Playwright (screenshots), Figma, etc. For data science workflows in particular, this makes Claude Code impractical for iterative notebook development with visualizations.

Related Closed Issues

  • #14150 — Same core issue (base64 saved to JSON file instead of rendered as image). Closed by inactivity bot, not by a fix.
  • #9152 — Token limit exceeded for MCP image responses. Closed as duplicate of #4002.
  • #4002 — File content exceeds 25K token limit. Closed.

Claude Code Version

Latest

Platform

macOS

Additional Context

The MCP server (datalayer/jupyter-mcp-server) correctly returns ImageContent objects with type="image", mimeType="image/png", and base64 data. The issue is entirely in how Claude Code handles these objects when constructing the API request.

View original on GitHub ↗

11 Comments

github-actions[bot] · 5 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/9152
  2. https://github.com/anthropics/claude-code/issues/14150
  3. https://github.com/anthropics/claude-code/issues/3597

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

davidsfeldman · 5 months ago

This is not a duplicate. The three issues flagged were all closed by inactivity bots, not by fixes:

  • #9152 — Closed as duplicate of #4002
  • #4002 — Closed (the 25K token limit for file content was addressed, but MCP ImageContent handling was not)
  • #14150 — Closed after 30 days of inactivity, explicitly noted as a regression that worked in v2.0.1x
  • #3597 — Closed, was about Playwright screenshots not being read at all in v1.0.53

The underlying bug remains: MCP ImageContent objects are not converted into native image content blocks for the Anthropic API. Instead, the base64 data is treated as text, either dumped into the context as a string or saved to a .txt file. This means:

  1. Each chart/image costs ~15,000-25,000 tokens as text instead of ~1,600 tokens as a native image
  2. Claude cannot actually interpret the image — it just sees base64 characters
  3. The same base64 data, when sent as a user attachment, works perfectly as a native image

This is blocking a key workflow for data scientists. Without this fix, Claude Code cannot create a notebook, visually analyze the charts it produces, and iteratively adjust — which is the core data science loop. Instead, users have to manually screenshot each chart, paste it back into Claude Code, describe what's wrong, and repeat. It turns what should be an autonomous end-to-end workflow into a tedious manual process that defeats the purpose of using an AI coding assistant.

This affects MCP servers that return images (Jupyter, and based on prior issues, Playwright and Figma as well) and has been reported multiple times over several months under different issue numbers. Please don't auto-close this again.

colinator · 5 months ago

Having a similar (probably the exact same) issue: my custom MCP server is returning, to claude code CLI inside a terminal:

{"result":[{"type":"image","data":"/9j/4AAQSkZJRgABAQAA...uaTaTRcLH/2Q==","mimeType":"image/jpeg" ... etc ...

I'm not getting token limit errors (only a warning about the size consuming context), but claude hallucinates the description when I ask it to describe what it sees. It's clearly not 'seeing' the image correctly.

For comparison, gemini cli does NOT have this issue - it correctly interprets the resulting images. OpenAI's codex has a similar, but different problem: depending on the llm harness/mcp client, it might truncate the result.

davidsfeldman · 5 months ago

Yeah are you seeing just letters and numbers for the image data? base46 encoding...

Claude Code should be able to read that and convert it to an image. But it's not, so it's a bug :/

colinator · 5 months ago
Claude Code should be able to read that and convert it to a

Yeah, the most disturbing thing is that claude just wholesale makes something up.

alipatti · 5 months ago

It seems like this has to do with whether or not the tool has an output schema. With a schema, the image isn't rendered. Without a schema, it is.

Minimal working example:

import base64
import io

from PIL import Image as PILImage, ImageDraw
from mcp.server.fastmcp import FastMCP
from mcp.types import ImageContent

mcp = FastMCP("Image Example")


def create_image() -> ImageContent:
    """Create a purple circle on black background."""

    img = PILImage.new("RGB", (100, 100), "white")
    draw = ImageDraw.Draw(img)
    draw.ellipse([0, 0, 99, 99], fill=(128, 0, 128))
    buf = io.BytesIO()
    img.save(buf, format="PNG")

    data = base64.b64encode(buf.getvalue()).decode()

    return ImageContent(
        type="image",
        data=data,
        mimeType="image/png",
    )


@mcp.tool()
def default() -> ImageContent:
    return create_image()


@mcp.tool(structured_output=False)
def no_structured_output() -> ImageContent:
    return create_image()


@mcp.tool()
def no_return_type():
    return create_image()


if __name__ == "__main__":
    mcp.run()

<img width="860" height="689" alt="Image" src="https://github.com/user-attachments/assets/3b32c1f3-a52a-4520-8fa1-1ae9b4019e7d" />

colinator · 5 months ago
It seems like this has to do with whether or not the tool has an output schema. With a schema, the image isn't rendered. Without a schema, it is.

Wow, good catch!

I don't know why an ImageContent would ever _not_ be run through image understanding.

davidsfeldman · 5 months ago

Root Cause Analysis Update

Building on @alipatti's excellent diagnostic above, I've traced the full root cause through the MCP Python SDK source code. Sharing here so the fix can be scoped precisely.

What's happening on the wire

When a tool has a return type annotation (e.g., -> list[str | ImageContent]), FastMCP auto-enables structured output. The SDK then sends both fields in the CallToolResult:

  • content: [TextContent, ImageContent] — proper content blocks with images intact
  • structuredContent: {"result": [{"type": "image", "data": "..."}]} — a JSON dict where ImageContent has been flattened via model_dump(mode="json")

The image data is already correct and available as a proper ImageContent block in the content array. Claude Code appears to prioritize structuredContent when present, discarding the content array entirely — which loses the type distinction between ImageContent and plain text.

Confirmed: bug is still present

As of Claude Code v2.1.79, the bug persists. Running a matplotlib chart through the Jupyter MCP server returns:

{"type":"image","data":"iVBORw0KGgo...","mimeType":"image/png"}

...as a JSON dict in the tool result, not as a native image content block. Claude receives ~20,000 tokens of base64 text instead of a ~1,600 token native image. Issues #15412 and #14150 are closed but the underlying behavior has not changed.

This is a cross-client pattern

VS Code has the same bug: microsoft/vscode#290063 — "MCP: structuredContent in tool result overrides content[].text sent to model." The issue is that MCP clients are treating structuredContent and content as mutually exclusive when they should be complementary — content for model-facing display (including images), structuredContent for programmatic access.

The fix

When processing MCP CallToolResult, Claude Code should always extract ImageContent blocks from the content array for display to the model, regardless of whether structuredContent is also present. The content array is the model-oriented output; structuredContent is the machine-oriented output. Images should never be downgraded to text.

Why this matters: data science workflows

This bug blocks a core use case: data scientists using Claude Code with Jupyter notebooks to iteratively create, view, and refine charts and analyses. The workflow should be: run a cell → Claude sees the chart → Claude suggests improvements → repeat. Instead, Claude either can't see the chart at all (base64 saved to a temp file) or hallucinates a description of an image it never actually processed. With 10 charts in a notebook, that's 150K-250K tokens of unusable base64 text.

A server-side workaround exists (structured_output=False on MCP tool decorators), but the real fix belongs here in the client — the data is already correct on the wire.

davidsfeldman · 5 months ago

If you're using the Jupyter MCP Server, if you use the commit from this change here https://github.com/datalayer/jupyter-mcp-server/pull/217 rather than the released version, you'll be able to have Claude Code see the charts as images!

It's a workaround and this issue should still be fixed.

github-actions[bot] · 3 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

github-actions[bot] · 2 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.