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