Skip to content

streamable HTTP client: the standalone GET stream advertises Accept: application/json but can only read text/event-stream, then gives up silently #3503

Description

@clemlesne

AI disclosure: I used Claude Code to read the transport, capture the headers and build the reproduction. I hit the problem myself, ran the repro, and stand behind the report.

Initial Checks

Release line

2.x (current stable)

Description

StreamableHTTPTransport._prepare_headers is written for the POST — accept: application/json, text/event-stream plus content-type: application/json — but all five outbound requests use it:

Call site Request
_handle_post_request POST with a body ok
handle_get_stream standalone SSE GET Accept overridden, Content-Type with no body
_handle_resumption_request resumption SSE GET same
_handle_reconnection reconnect SSE GET same
terminate_session DELETE, no body Content-Type with no body

sse_within_origin sets _SSE_HEADERS = {"Accept": "text/event-stream", ...} and then does merged.update(headers or {}), so the caller's Accept wins. Those GET responses are read only through httpx2.EventSource, which raises SSEError for anything but text/event-stream: the client offers a representation it will then refuse. On the wire:

GET /mcp
  accept: application/json, text/event-stream
  content-type: application/json

The DELETE carries the same content-type with no body.

What it costs. Answer that GET with application/json and the offer is honoured back at the client:

httpx2.SSEError: Expected response with content type 'text/event-stream', got 'application/json'.
DEBUG mcp.client.streamable_http GET stream max reconnection attempts (2) exceeded

handle_get_stream swallows it in its broad except Exception and returns. Client-to-server requests keep working — the repro still prints its tools/call result — so the session looks healthy while notifications, sampling, elicitation and roots are gone for its lifetime, with only a DEBUG line.

Expected. The SSE GETs advertise only text/event-stream, and the bodyless GET and DELETE carry no Content-Type.

Scope. Under the 2025-11-25 spec the client is within its rights (it "MUST include an Accept header, listing text/event-stream as a supported content type"; listing more is not forbidden) and the server is not (it "MUST either return Content-Type: text/event-stream [...] or else return HTTP 405 Method Not Allowed"). A compliant server never hits this, and the repro's server is deliberately non-compliant. What still looks wrong is offering what the client cannot read, and losing the channel silently when a server takes the offer up; the stray Content-Type is wrong regardless. mcp/client/sse.py is unaffected — it passes no headers.

The repro uses mode="legacy" because notifications/initialized is sent only from ClientSession.initialize(); the modern path adopts a DiscoverResult, so start_get_stream never fires there.

Example Code

# server.py — the official server, answering the GET with the content type the client accepts.
import uvicorn
from mcp.server.mcpserver import MCPServer

server = MCPServer(name="probe", version="0.1.0")


@server.tool()
def echo(text: str) -> str:
    return text


class TakeTheClientAtItsWord:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] == "http" and scope["method"] == "GET":
            body = b'{"jsonrpc":"2.0"}'
            await send({
                "type": "http.response.start",
                "status": 200,
                "headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())],
            })
            await send({"type": "http.response.body", "body": body})
            return
        await self.app(scope, receive, send)


uvicorn.run(TakeTheClientAtItsWord(server.streamable_http_app()), host="127.0.0.1", port=8000)
# client.py
import asyncio
import logging

from mcp import Client

logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s %(message)s")


async def main() -> None:
    async with Client("http://127.0.0.1:8000/mcp", mode="legacy") as client:
        print("tools/call still works:", (await client.call_tool("echo", {"text": "hi"})).content[0].text)
        await asyncio.sleep(3)


asyncio.run(main())

Drop the GET branch from the wrapper and log scope["headers"] to see the outgoing headers instead of the failure.

Python & MCP Python SDK

Python 3.14.3 (CPython, macOS arm64)
mcp 2.2.0, httpx2 2.12.0, httpcore2 2.12.0, anyio 4.15.1, uvicorn 0.52.4

Activity

  1. added
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Sep 14, 2026
  2. Rainmemery commented on Sep 15, 2026

    @Rainmemery

    I can confirm the mechanism from reading the transport at 9972c21a - this is a clean find.

    StreamableHTTPTransport._prepare_headers() hardcodes the POST shape (accept: application/json, text/event-stream + content-type: application/json), and all five outbound call sites use it. The three SSE GETs (handle_get_stream, _handle_resumption_request, _handle_reconnection) then pass those headers into sse_within_origin, whose merge is merged = Headers(_SSE_HEADERS); merged.update(headers or {}) - so the caller's Accept overrides the text/event-stream that EventSource can actually read. If a non-compliant server takes the offer and answers the GET with application/json, httpx2.EventSource raises SSEError, handle_get_stream swallows it in its broad except Exception, the reconnect loop burns its two attempts and returns - notifications/sampling/elicitation/roots are gone for the session with only a DEBUG line, while client-to-server POSTs keep working. The stray content-type on the bodyless GET and DELETE is wrong regardless.

    The fix I'd propose keeps _prepare_headers() as the POST shape and parameterizes it (accept, optional content_type):

    • the three SSE GETs pass accept="text/event-stream", content_type=None (a compliant server never reads the JSON offer anyway, and the client refuses it, so advertising it only invites this failure);
    • terminate_session passes content_type=None (no body, no content-type);
    • _handle_post_request stays on the defaults, so the POST wire shape is unchanged.

    POST defaults untouched means the 2025-11-25 GET/POST requirements and sse.py are unaffected.

    I have this implemented with a MockTransport-based regression test pinning the three wire shapes (SSE GET accept-only, bodyless GET/DELETE without content-type, POST unchanged) plus the existing interaction suite passing (80/80). If a maintainer assigns this issue I'll open the PR right away (happy to target main, and to backport the same shape to v1.x since both files are byte-identical).

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