Skip to content

images.py example returns raw pixels from tobytes() labelled image/png, so clients cannot decode the thumbnail #3805

Open

Description

@chrikrah

Initial Checks

Release line

2.x (current stable)

Description

create_thumbnail in examples/snippets/servers/images.py returns Image(data=img.tobytes(), format="png"). Pillow's tobytes() gives raw pixel data, not a PNG file, so the tool sends 22,500 bytes of raw RGB labelled image/png, and a client cannot decode them. The ImageContent schema asks for "the base64-encoded image data" with its MIME type, and docs/servers/media.md names Pillow output as the data= case and warns that a client handed the wrong type "faithfully fails to decode it".

On main this example is no longer rendered into the docs or imported by tests/server/mcpserver/test_integration.py, so it could be fixed or deleted. The copy readers see is the rendered block at docs/server.md:688 on v1.x.

The fix is two lines: buf = io.BytesIO(); img.save(buf, format="PNG"), then pass buf.getvalue().

Example Code

import base64, io, sys, anyio
import mcp
from PIL import Image as PILImage
sys.path.insert(0, sys.argv[1])  # tree root, for examples.snippets
from mcp import Client
from examples.snippets.servers.images import mcp as server

print(mcp.__file__)
PILImage.new("RGB", (400, 300), (200, 30, 30)).save("in.png")

async def main():
    async with Client(server) as c:
        r = await c.call_tool("create_thumbnail", {"image_path": "in.png"})
        block = r.content[0]
        raw = base64.b64decode(block.data)
        print(block.mime_type, len(raw), "bytes, starts", raw[:8])
        try:
            print(PILImage.open(io.BytesIO(raw)).format)
        except Exception as e:
            print(type(e).__name__, e)
anyio.run(main)
$ .venv/bin/python ../repro_thumbnail.py $PWD
.../src/mcp/__init__.py
image/png 22500 bytes, starts b'\xc8\x1e\x1e\xc8\x1e\x1e\xc8\x1e'
UnidentifiedImageError cannot identify image file <_io.BytesIO object at 0x79f9afe5c950>
# not run: a real client such as Claude Desktop

Python & MCP Python SDK

Python 3.14.7
mcp at main d7845c0; the v1.x branch at 52e64dd has the same snippet in docs/server.md

I can send that fix for main, with images added to the test_integration.py imports, if you assign this to me. Whether v1.x needs it is your call.

Activity

  1. Techsynx commented on Nov 3, 2026

    @Techsynx

    Hi! I've opened a fix for this in #3809. It switches img.tobytes() to io.BytesIO() + img.save(buffer, format="PNG") so that valid PNG containers and chunks are returned, and adds an integration test verifying decoded PNG bytes and dimensions.

    Could a maintainer please assign this issue to @Techsynx to align with the repository's PR intake policy? Thank you!

  2. Techsynx commented on Nov 3, 2026

    @Techsynx

    Reproduction & Technical Analysis

    I confirmed the reproduction on Python 3.13 / Pillow 11.1.0:

    from PIL import Image as PILImage
    import io
    
    img = PILImage.new("RGB", (200, 200), (255, 0, 0))
    img.thumbnail((100, 100))
    raw_bytes = img.tobytes()
    
    # Client side decoding attempt:
    try:
        PILImage.open(io.BytesIO(raw_bytes))
    except Exception as e:
        print("Decode failed:", type(e).__name__, e)
    # Output: Decode failed: UnidentifiedImageError cannot identify image file <_io.BytesIO object>

    Why it happens

    img.tobytes() extracts uncompressed raw raster scanline bytes from memory (len = 100 * 100 * 3 = 30,000 bytes) with zero file headers or chunk frames. Returning this with format="png" (MIME image/png) produces invalid PNG streams that standard viewers and PIL reject.

    Verified Solution

    Writing to an in-memory stream via io.BytesIO():

    buffer = io.BytesIO()
    img.save(buffer, format="PNG")
    return Image(data=buffer.getvalue(), format="png")

    This produces valid PNG byte streams (with standard PNG magic bytes \x89PNG\r\n\x1a\n and IHDR/IDAT/IEND chunks) and correctly decodes across clients.

    We have this fix implemented and verified with an integration test in PR #3809 (tests/server/mcpserver/test_integration.py::test_images_thumbnail), and are ready to support review whenever a maintainer is available.

  3. added
    documentationImprovements or additions to documentation
    v1Affects the v1.x maintenance line
    v2Affects the v2 line (2.x on main)
    on Nov 3, 2026
  4. media-ctrl commented on Nov 5, 2026

    @media-ctrl

    The report is correct on current main, and the numbers are findable exactly.

    examples/snippets/servers/images.py:15 still returns Image(data=img.tobytes(), format="png") after img.thumbnail((100, 100)). I ran the example's own logic against a 400x300 source and reproduced every figure:

    thumb size: (100, 75) -> tobytes(): 22500 bytes, starts b'\xc8\x1e\x1e\xc8\x1e\x1e'
    save(PNG): 242 bytes, starts b'\x89PNG\r\n\x1a\n'
    roundtrip tobytes decodes? UnidentifiedImageError: cannot identify image file
    roundtrip PNG decodes?     PNG (100, 75)
    

    So 100 x 75 x 3 channels is exactly the 22,500 bytes you measured, the leading bytes are the pixel value repeated, and Pillow cannot reopen them. Two independent defects in one line: the payload is not the format it claims, and it is 93 times larger than the correct one.

    One thing worth adding to the argument for fixing rather than deleting the example. Deletion is defensible on the grounds that it is no longer rendered or imported on main, but the cost of a wrong media example is asymmetric: a reader copying it gets a tool that silently ships an undecodable blob, and the failure appears on the client side as a decode error with no line of their code in the traceback. That is much harder to diagnose than an import error from a deleted file. If the example stays, it should stay correct.

    For the fix, buf.getvalue() is right, and one addition makes the example less reproducible as a bug: pass the format explicitly on the save and derive the MIME type from it, so the label and the bytes cannot drift apart again.

    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return Image(data=buf.getvalue(), format="png")

    img.format is a trap here for the general case, because it is None on an image constructed in memory and it survives thumbnail(), so an example that derived the label from it would work on a file and fail on a synthetic image. A small helper that maps one explicit format constant to both the encoder and the MIME string keeps the two ends in step for the JPEG and WebP variants that will follow.

  5. Techsynx commented on Nov 5, 2026

    @Techsynx

    Thanks @media-ctrl for the thorough verification and numbers! The 93x payload disparity (22.5 KB raw scanlines vs. 242 B compressed PNG) and the breakdown of why tobytes() breaks round-trip decoding illustrate the defect clearly.

    I also strongly agree with the asymmetric cost rationale for keeping and fixing the snippet rather than deleting it. Documentation and snippet samples are often copied directly into downstream MCP servers, where an undecodable blob manifests as a cryptic client-side decode error with no server stack trace.

    Your point about img.format is spot on as well—since img.format evaluates to None on synthetic/in-memory images and after transformations like thumbnail(), explicitly passing format="PNG" to img.save(buffer, format="PNG") and matching format="png" ensures the byte stream and the MIME label never drift.

    This is exactly the pattern implemented in PR #3809 (which also adds an integration test verifying the round-trip decoding and dimensions via PIL). Once a maintainer has capacity to review and assign the issue, the PR will reopen automatically and we'll be glad to shepherd it through.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationv1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions