Skip to content

HttpResource silently decodes PNG response bytes into lossy TextResourceContents #3843

Open

Description

@tasuku-kobayashi

Initial Checks

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

  1. added
    bugSomething isn't working
    v1Affects the v1.x maintenance line
    v2Affects the v2 line (2.x on main)
    on Nov 9, 2026
  2. vanshaj1999 commented on Nov 9, 2026

    @vanshaj1999

    Hi, I'd like to help with this if @tasuku-kobayashi isn't planning a PR.

    My plan: in HttpResource.read(), return response.content (bytes) when the response is not text, so Client.read_resource() yields BlobResourceContents, and keep text for text/* 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.

  3. media-ctrl commented on Nov 9, 2026

    @media-ctrl

    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:

    image://control BlobResourceContents bytes_exact= True len= 69
    image://remote TextResourceContents mime= image/png len= 69 reencoded_bytes= 95 has_replacement= True
    

    So this is in the release line, not only on main. The HTTP leg serves a real 1x1 RGB PNG from a loopback http.server with Content-Type: image/png, read through the public Client.read_resource, and the re-encode is UTF-8 with errors="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() returns str | bytes, and FunctionResource.read() branches at src/mcp/server/mcpserver/resources/types.py:98 (elif isinstance(result, bytes): return result), so a JSON resource returned as bytes becomes BlobResourceContents and any client that parses JSON from text stops 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 declared mime_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 says image/png and so does the resource, but nothing in the current code compares either one to the payload.

    A workable rule, in order of precedence:

    1. Use the response Content-Type when it is present. Text is text/*, application/json, any +json suffix, application/xml, any +xml suffix, and application/javascript. Everything else returns response.content.
    2. When the header is missing or unusable (no /), fall back to the resource's mime_type with the same set.
    3. When both are absent, keep today's behaviour and return response.text, so nothing that works now changes.

    Decoding should use response.encoding for the text branch rather than response.text's charset guessing, or a charset= 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 BlobResourceContents whose base64 decodes byte-for-byte to the served bytes, and a application/json body must still arrive as TextResourceContents. 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.

  4. tasuku-kobayashi commented on Nov 10, 2026

    @tasuku-kobayashi
    Author

    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.

  5. vanshaj1999 commented on Nov 10, 2026

    @vanshaj1999

    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.

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

    bugSomething isn't workingv1Affects 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