From d5bd2a9c524ecf60f7276129f6c9a49fe1e57af9 Mon Sep 17 00:00:00 2001 From: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:57:50 -0700 Subject: [PATCH] fix(client): give SSE GET and DELETE requests their own header shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: StreamableHTTPTransport._prepare_headers() always built the POST shape (Accept: application/json, text/event-stream + Content-Type: application/json), and every outbound call site reused it verbatim, including the three standalone SSE GET paths (handle_get_stream, _handle_resumption_request, _handle_reconnection) and the bodyless session-termination DELETE. sse_within_origin's header merge lets a caller-supplied Accept override its own text/event-stream-only default, so the SSE GETs advertised "application/json, text/event-stream" even though httpx2.EventSource can only parse text/event-stream. A server that takes that offer at its word and answers the GET with application/json causes EventSource to raise SSEError; handle_get_stream swallows that in a broad except Exception, so the notification/sampling/elicitation/roots channel is silently lost for the rest of the session while client-to-server POSTs keep working (only a DEBUG log line marks it). The bodyless GET and DELETE also carried a stray Content-Type with no body behind it, regardless of server behavior. The 2025-11-25 spec explicitly makes the wrong side the server here (it must answer text/event-stream or 405), so a spec-compliant server never triggers the failure mode above — but the client still offers a representation it cannot read, and loses the channel silently the one time a server takes it at its word. Approach: Parameterize _prepare_headers() with keyword-only `accept` and `content_type` arguments, defaulting to the existing POST shape so _handle_post_request is unaffected. The three SSE GET call sites now pass accept="text/event-stream", content_type=None. terminate_session's DELETE now passes content_type=None, keeping its existing Accept default since only the stray Content-Type was wrong there. Validation: - Added test_prepare_headers_matches_each_call_sites_request_shape in tests/client/test_streamable_http.py, pinning the exact header dict for the POST, SSE-GET, and DELETE shapes. Confirmed it fails against the pre-fix code (TypeError: _prepare_headers() got an unexpected keyword argument 'accept') and passes post-fix. - uv run --frozen pytest tests/client/test_streamable_http.py -q: 37 passed. - ./scripts/test: 5969 passed, 10 skipped, 1 xfailed, 100% coverage, strict-no-cover clean. - uv run --frozen ruff format / ruff check / pyright on the changed files: clean. - Grepped the repo for other _prepare_headers() callers: none outside this file and its test, so no caller depends on the old unconditional Content-Type. This change does not alter any user-visible request/response behavior against a spec-compliant server; the practical benefit is that a non-compliant server answering the GET with JSON no longer desyncs the notification channel, and the DELETE/bodyless-GET requests no longer carry a Content-Type with no body. AI disclosure: implemented with Claude Code, which read the transport code and the linked issue, wrote the fix and test, and ran the validation commands above. I reviewed the diff and the failing-then-passing test result and stand behind the change. Report: https://github.com/modelcontextprotocol/python-sdk/issues/3503 Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Assisted-by: claude-sonnet-5 (via Claude Code) --- src/mcp/client/streamable_http.py | 28 +++++++++++++++++++--------- tests/client/test_streamable_http.py | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 82de50fd05..ca7442b883 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -131,7 +131,12 @@ def __init__(self, url: str) -> None: # `_consume_modern_cancellation`. Keys are verbatim-typed ("1" is not 1). self._in_flight_posts: dict[RequestId, _InFlightPost] = {} - def _prepare_headers(self) -> dict[str, str]: + def _prepare_headers( + self, + *, + accept: str = "application/json, text/event-stream", + content_type: str | None = "application/json", + ) -> dict[str, str]: """Build MCP-specific request headers for any outbound HTTP request. These are merged with the ``httpx2.AsyncClient`` defaults (these take @@ -140,11 +145,16 @@ def _prepare_headers(self) -> dict[str, str]: response/error POSTs, legacy cancel frames, transport-internal GET/DELETE — still carry the negotiated version. Per-message headers are layered on top by the caller. + + The defaults are the POST shape. SSE GETs pass ``accept="text/event-stream", + content_type=None``: `EventSource` can only read that content type, so offering + ``application/json`` too just invites a compliant-looking server to answer with + the one type the client will then refuse. The bodyless DELETE passes + ``content_type=None`` since it sends no body. """ - headers: dict[str, str] = { - "accept": "application/json, text/event-stream", - "content-type": "application/json", - } + headers: dict[str, str] = {"accept": accept} + if content_type is not None: + headers["content-type"] = content_type if self.session_id: headers[MCP_SESSION_ID] = self.session_id if self._protocol_version_header: @@ -227,7 +237,7 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer if not self.session_id: return - headers = self._prepare_headers() + headers = self._prepare_headers(accept="text/event-stream", content_type=None) if last_event_id: headers[LAST_EVENT_ID] = last_event_id @@ -267,7 +277,7 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer async def _handle_resumption_request(self, ctx: RequestContext) -> None: """Handle a resumption request using GET with SSE.""" - headers = self._prepare_headers() + headers = self._prepare_headers(accept="text/event-stream", content_type=None) if ctx.metadata and ctx.metadata.resumption_token: headers[LAST_EVENT_ID] = ctx.metadata.resumption_token else: @@ -538,7 +548,7 @@ async def _handle_reconnection( delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS await anyio.sleep(delay_ms / 1000.0) - headers = self._prepare_headers() + headers = self._prepare_headers(accept="text/event-stream", content_type=None) headers[LAST_EVENT_ID] = last_event_id try: @@ -666,7 +676,7 @@ async def terminate_session(self, client: httpx2.AsyncClient) -> None: return # pragma: no cover try: - headers = self._prepare_headers() + headers = self._prepare_headers(content_type=None) response = await request_within_origin(client, "DELETE", self.url, headers=headers) if response.status_code == 405: diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index c6e62ad94a..fc07f7eddd 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -37,6 +37,7 @@ from mcp import Client, MCPError from mcp.client.streamable_http import ( MAX_RECONNECTION_ATTEMPTS, + MCP_SESSION_ID, RequestContext, StreamableHTTPTransport, streamable_http_client, @@ -87,6 +88,31 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field assert encoded == raw +def test_prepare_headers_matches_each_call_sites_request_shape() -> None: + """POST keeps the dual-content-type default; SSE GETs advertise only text/event-stream and no + Content-Type; the bodyless DELETE drops Content-Type but keeps the dual-content-type Accept + (SDK-defined: `_prepare_headers` is shared by every outbound request, so each call site's + `accept`/`content_type` arguments are pinned here rather than only through end-to-end transport + tests, since `EventSource` accepts nothing but `text/event-stream` and a server that takes the + unparameterized default's JSON offer at its word desyncs the GET stream silently).""" + transport = StreamableHTTPTransport("http://test/mcp") + transport.session_id = "session-1" + + post = transport._prepare_headers() # pyright: ignore[reportPrivateUsage] + sse_get = transport._prepare_headers( # pyright: ignore[reportPrivateUsage] + accept="text/event-stream", content_type=None + ) + delete = transport._prepare_headers(content_type=None) # pyright: ignore[reportPrivateUsage] + + assert post == { + "accept": "application/json, text/event-stream", + "content-type": "application/json", + MCP_SESSION_ID: "session-1", + } + assert sse_get == {"accept": "text/event-stream", MCP_SESSION_ID: "session-1"} + assert delete == {"accept": "application/json, text/event-stream", MCP_SESSION_ID: "session-1"} + + @pytest.mark.anyio async def test_post_request_merges_per_message_metadata_headers() -> None: """`ClientMessageMetadata.headers` on a `SessionMessage` are merged into the outgoing POST headers