You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
images.py example returns raw pixels from tobytes() labelled image/png, so clients cannot decode the thumbnail #3805
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().
$ .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.
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!
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.
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.
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:
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.
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.
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.
Activity
Techsynx commentedon Nov 3, 2026
Hi! I've opened a fix for this in #3809. It switches
img.tobytes()toio.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!
Techsynx commentedon Nov 3, 2026
Reproduction & Technical Analysis
I confirmed the reproduction on Python 3.13 / Pillow 11.1.0:
Why it happens
img.tobytes()extracts uncompressed raw raster scanline bytes from memory (len = 100 * 100 * 3 = 30,000bytes) with zero file headers or chunk frames. Returning this withformat="png"(MIMEimage/png) produces invalid PNG streams that standard viewers and PIL reject.Verified Solution
Writing to an in-memory stream via
io.BytesIO():This produces valid PNG byte streams (with standard
PNGmagic bytes\x89PNG\r\n\x1a\nandIHDR/IDAT/IENDchunks) 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.media-ctrl commentedon Nov 5, 2026
The report is correct on current main, and the numbers are findable exactly.
examples/snippets/servers/images.py:15still returnsImage(data=img.tobytes(), format="png")afterimg.thumbnail((100, 100)). I ran the example's own logic against a 400x300 source and reproduced every figure: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.img.formatis a trap here for the general case, because it isNoneon an image constructed in memory and it survivesthumbnail(), 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.Techsynx commentedon Nov 5, 2026
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.formatis spot on as well—sinceimg.formatevaluates toNoneon synthetic/in-memory images and after transformations likethumbnail(), explicitly passingformat="PNG"toimg.save(buffer, format="PNG")and matchingformat="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.