Repository navigation
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
2.x (current stable)
Description
While checking HTTP-hosted figures for MCP resource delivery, I found that HttpResource silently converts a binary HTTP response into lossy text, even when both the resource and the HTTP response declare image/png.
A valid 69-byte PNG served with HTTP 200 reaches Client(server).read_resource() as TextResourceContents containing U+FFFD replacement characters. Re-encoding that string as UTF-8 produces 95 bytes, so the original image cannot be recovered. The MIME label remains image/png and the read reports success.
The same bytes served through BinaryResource arrive as BlobResourceContents and decode back to the original 69 bytes. A separate HTTP JSON resource containing UTF-8 Japanese text is also preserved correctly.
Expected: preserve the binary response bytes and return blob content. If binary HTTP resources are intentionally unsupported, reject them clearly rather than returning success after lossy text conversion.
The apparent cause is HttpResource.read() returning response.text unconditionally after raise_for_status() in src/mcp/server/mcpserver/resources/types.py. This happens before MCP's text/blob conversion. It is separate from the old base64-encoding issue #342.
The reproducer below uses a real loopback HTTP server and the public MCP client API, without mocks or SDK patches. It requires only mcp and its dependencies. The embedded image is a valid 1x1 RGB PNG; its chunk CRCs and decompressed pixel data were independently checked.
Observed output, omitting HTTP request logging:
image://control BlobResourceContents True
image://remote TextResourceContents False
AI assistance was used to investigate this behavior, create and execute the reproducer, and draft this report.
Example Code
import base64
import anyio
from anyio.abc import SocketAttribute, SocketStream
from mcp_types import BlobResourceContents, TextResourceContents
from mcp import Client
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.resources import BinaryResource, HttpResource
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC")
async def http_peer(stream: SocketStream) -> None:
request = b""
while b"\r\n\r\n" not in request:
request += await stream.receive(4096)
assert request.startswith(b"GET /figure.png ")
headers = f"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {len(PNG)}\r\nConnection: close\r\n\r\n"
await stream.send(headers.encode("ascii") + PNG)
await stream.aclose()
async def main() -> None:
listener = await anyio.create_tcp_listener(local_host="127.0.0.1", local_port=0)
port = listener.extra(SocketAttribute.local_address)[1]
server = MCPServer()
server.add_resource(BinaryResource(uri="image://control", data=PNG, mime_type="image/png"))
server.add_resource(
HttpResource(uri="image://remote", url=f"http://127.0.0.1:{port}/figure.png", mime_type="image/png")
)
async with listener, anyio.create_task_group() as group:
group.start_soon(listener.serve, http_peer)
with anyio.fail_after(5):
async with Client(server) as client:
for uri in ("image://control", "image://remote"):
result = await client.read_resource(uri)
content = result.contents[0]
if isinstance(content, BlobResourceContents):
received = base64.b64decode(content.blob)
else:
assert isinstance(content, TextResourceContents)
received = content.text.encode("utf-8")
print(uri, type(content).__name__, received == PNG)
group.cancel_scope.cancel()
anyio.run(main)Python & MCP Python SDK
CPython 3.12.14; Linux x86_64, glibc 2.39
Released wheel:
mcp 2.3.0
mcp-types 2.3.0
httpx2 2.16.0
pydantic 2.14.3
anyio 4.16.0
Also reproduced on main:
77001c96f9507be0f4d009b83210a384a5e08bda
Repository frozen dependencies:
httpx2 2.12.0
pydantic 2.12.5
anyio 4.14.2
Activity
vanshaj1999 commentedon Nov 9, 2026
Hi, I'd like to help with this if @tasuku-kobayashi isn't planning a PR.
My plan: in
HttpResource.read(), returnresponse.content(bytes) when the response is not text, soClient.read_resource()yieldsBlobResourceContents, and keep text fortext/*and JSON types. Tests would cover PNG bytes coming through unchanged and Unicode text staying text. Happy to use a different MIME rule if you prefer one.Could I be assigned? AI disclosure: drafted with Claude Code and reviewed by me. Any PR will name the tools used and how much they did.
media-ctrl commentedon Nov 9, 2026
Reproduced independently on mcp 2.3.0 rather than main, with the control and the HTTP case in the same run, and the numbers match yours exactly:
So this is in the release line, not only on main. The HTTP leg serves a real 1x1 RGB PNG from a loopback
http.serverwithContent-Type: image/png, read through the publicClient.read_resource, and the re-encode is UTF-8 witherrors="strict"replaced by the default, hence 95 bytes and U+FFFD.On the rule for the fix, one thing to be careful about with the proposal in the first comment. The text blob split downstream is by Python type, not by MIME:
HttpResource.read()returnsstr | bytes, andFunctionResource.read()branches atsrc/mcp/server/mcpserver/resources/types.py:98(elif isinstance(result, bytes): return result), so a JSON resource returned as bytes becomesBlobResourceContentsand any client that parses JSON fromtextstops working. The gate therefore has to be explicit about what counts as text, and it should read the type from the HTTP response rather than from the resource's declaredmime_type. The declared type is set by the server author and can disagree with the bytes on the wire, which is the exact case in this report: the response saysimage/pngand so does the resource, but nothing in the current code compares either one to the payload.A workable rule, in order of precedence:
Content-Typewhen it is present. Text istext/*,application/json, any+jsonsuffix,application/xml, any+xmlsuffix, andapplication/javascript. Everything else returnsresponse.content./), fall back to the resource'smime_typewith the same set.response.text, so nothing that works now changes.Decoding should use
response.encodingfor the text branch rather thanresponse.text's charset guessing, or acharset=parameter the header does not carry gets guessed from content, which is the same class of bug one layer up.Two test assertions would lock it: a PNG body must come back as
BlobResourceContentswhose base64 decodes byte-for-byte to the served bytes, and aapplication/jsonbody must still arrive asTextResourceContents. The second one is the one a broad "return bytes unless the resource says text" change breaks silently, because the read still succeeds and only the content class changes.tasuku-kobayashi commentedon Nov 10, 2026
Thanks for reproducing this on 2.3.0 and highlighting the JSON compatibility requirement. I’m not preparing a PR, so I’m happy for @vanshaj1999 to take it forward if the maintainers agree.
The key regression checks are byte-exact PNG delivery and unchanged Unicode JSON text delivery. MIME precedence, charset handling and fallback behavior should follow the maintainers’ decision.
vanshaj1999 commentedon Nov 10, 2026
Thanks @media-ctrl, good catch on the str/bytes split, and thanks @tasuku-kobayashi. Agreed: the response Content-Type decides, the declared mime_type is only the fallback, and JSON and +json stay text. Tests: a PNG byte for byte as BlobResourceContents, and Unicode JSON still as TextResourceContents. I'll wait for a maintainer before I open a PR.