From 1ab51df379453463e44bd2ef38188816a714d228 Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sat, 28 Mar 2026 04:13:50 +0530 Subject: [PATCH 1/2] Fix Streamable HTTP Accept negotiation --- src/mcp/server/streamable_http.py | 18 +++++-- ...est_1363_race_condition_streamable_http.py | 14 +++--- tests/shared/test_streamable_http.py | 49 ++++++++++++++++++- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index aa99e7c88..7544d798d 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -426,16 +426,24 @@ async def _validate_accept_header(self, request: Request, scope: Scope, send: Se ) await response(scope, request.receive, send) return False - # For SSE responses, require both content types - elif not (has_json and has_sse): + # For SSE-capable responses, accept either JSON or SSE and negotiate later. + elif not (has_json or has_sse): response = self._create_error_response( - "Not Acceptable: Client must accept both application/json and text/event-stream", + "Not Acceptable: Client must accept application/json or text/event-stream", HTTPStatus.NOT_ACCEPTABLE, ) await response(scope, request.receive, send) return False return True + def _should_use_json_response(self, request: Request) -> bool: + """Choose JSON when required or when the client does not accept SSE.""" + if self.is_json_response_enabled: + return True + + has_json, has_sse = self._check_accept_headers(request) + return has_json and not has_sse + async def _handle_post_request(self, scope: Scope, request: Request, receive: Receive, send: Send) -> None: """Handle POST requests containing JSON-RPC messages.""" writer = self._read_stream_writer @@ -476,6 +484,8 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re await response(scope, receive, send) return + use_json_response = self._should_use_json_response(request) + # Check if this is an initialization request is_initialization_request = isinstance(message, JSONRPCRequest) and message.method == "initialize" @@ -527,7 +537,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage](0) request_stream_reader = self._request_streams[request_id][1] - if self.is_json_response_enabled: + if use_json_response: # Process the message metadata = ServerMessageMetadata(request_context=request) session_message = SessionMessage(message, metadata=metadata) diff --git a/tests/issues/test_1363_race_condition_streamable_http.py b/tests/issues/test_1363_race_condition_streamable_http.py index db2a82d07..da88e45b0 100644 --- a/tests/issues/test_1363_race_condition_streamable_http.py +++ b/tests/issues/test_1363_race_condition_streamable_http.py @@ -123,7 +123,7 @@ async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFi """Test the race condition with invalid Accept headers. This test reproduces the exact scenario described in issue #1363: - - Send POST request with incorrect Accept headers (missing either application/json or text/event-stream) + - Send POST request with incorrect Accept headers that match neither JSON nor SSE - Request fails validation early and returns quickly - This should trigger the race condition where message_router encounters ClosedResourceError """ @@ -137,7 +137,7 @@ async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFi # Suppress WARNING logs (expected validation errors) and capture ERROR logs with caplog.at_level(logging.ERROR): - # Test with missing text/event-stream in Accept header + # Test with an incompatible text media type async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 ) as client: @@ -145,14 +145,14 @@ async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFi "/", json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, headers={ - "Accept": "application/json", # Missing text/event-stream + "Accept": "text/plain", "Content-Type": "application/json", }, ) - # Should get 406 Not Acceptable due to missing text/event-stream + # Should get 406 Not Acceptable for an unsupported media type assert response.status_code == 406 - # Test with missing application/json in Accept header + # Test with an incompatible application media type async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 ) as client: @@ -160,11 +160,11 @@ async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFi "/", json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, headers={ - "Accept": "text/event-stream", # Missing application/json + "Accept": "application/xml", "Content-Type": "application/json", }, ) - # Should get 406 Not Acceptable due to missing application/json + # Should get 406 Not Acceptable for an unsupported media type assert response.status_code == 406 # Test with completely invalid Accept header diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index f8ca30441..ed6ee9b77 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -608,12 +608,57 @@ def test_accept_header_wildcard(basic_server: None, basic_server_url: str, accep assert response.status_code == 200 +@pytest.mark.parametrize( + ("accept_header", "expected_content_type"), + [ + ("application/json", "application/json"), + ("text/event-stream", "text/event-stream"), + ], +) +def test_accept_header_single_media_type_negotiates_response( + basic_server: None, basic_server_url: str, accept_header: str, expected_content_type: str +): + """Test that SSE-capable servers negotiate JSON or SSE from a single accepted media type.""" + mcp_url = f"{basic_server_url}/mcp" + init_response = requests.post( + mcp_url, + headers={ + "Accept": accept_header, + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert init_response.status_code == 200 + assert init_response.headers.get("Content-Type") == expected_content_type + + session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) + assert session_id is not None + + if expected_content_type == "application/json": + negotiated_version = init_response.json()["result"]["protocolVersion"] + else: + negotiated_version = extract_protocol_version_from_sse(init_response) + + tools_response = requests.post( + mcp_url, + headers={ + "Accept": accept_header, + "Content-Type": "application/json", + MCP_SESSION_ID_HEADER: session_id, + MCP_PROTOCOL_VERSION_HEADER: negotiated_version, + }, + json={"jsonrpc": "2.0", "method": "tools/list", "id": "tools-accept-single-media"}, + ) + assert tools_response.status_code == 200 + assert tools_response.headers.get("Content-Type") == expected_content_type + + @pytest.mark.parametrize( "accept_header", [ "text/html", - "application/*", - "text/*", + "text/plain", + "application/xml", ], ) def test_accept_header_incompatible(basic_server: None, basic_server_url: str, accept_header: str): From b188f994a4a350c3c21d206d8281b47dc8af40dc Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:35:44 +0530 Subject: [PATCH 2/2] fix: honor q=0 in streamable HTTP Accept negotiation --- src/mcp/server/streamable_http.py | 20 +++++++++++++++++++- tests/shared/test_streamable_http.py | 17 ++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 7544d798d..564667619 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -397,9 +397,27 @@ def _check_accept_headers(self, request: Request) -> tuple[bool, bool]: - */* matches any media type - application/* matches any application/ subtype - text/* matches any text/ subtype + - media types with q=0 are treated as unacceptable """ accept_header = request.headers.get("accept", "") - accept_types = [media_type.strip().split(";")[0].strip().lower() for media_type in accept_header.split(",")] + accept_types: list[str] = [] + for media_range in accept_header.split(","): + parts = [part.strip().lower() for part in media_range.split(";")] + media_type = parts[0] + if not media_type: + continue + + quality = 1.0 + for param in parts[1:]: + if param.startswith("q="): + try: + quality = float(param[2:]) + except ValueError: + pass + break + + if quality > 0: + accept_types.append(media_type) has_wildcard = "*/*" in accept_types has_json = has_wildcard or any(t in (CONTENT_TYPE_JSON, "application/*") for t in accept_types) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index ed6ee9b77..c23910d5d 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -613,6 +613,8 @@ def test_accept_header_wildcard(basic_server: None, basic_server_url: str, accep [ ("application/json", "application/json"), ("text/event-stream", "text/event-stream"), + ("application/json;q=0.9, text/event-stream;q=0", "application/json"), + ("text/event-stream;q=0.9, application/json;q=0", "text/event-stream"), ], ) def test_accept_header_single_media_type_negotiates_response( @@ -659,6 +661,7 @@ def test_accept_header_single_media_type_negotiates_response( "text/html", "text/plain", "application/xml", + "application/json;q=0, text/event-stream;q=0", ], ) def test_accept_header_incompatible(basic_server: None, basic_server_url: str, accept_header: str): @@ -933,14 +936,22 @@ def test_json_response_missing_accept_header(json_response_server: None, json_se assert "Not Acceptable" in response.text -def test_json_response_incorrect_accept_header(json_response_server: None, json_server_url: str): +@pytest.mark.parametrize( + "accept_header", + [ + "text/event-stream", + "application/json;q=0, text/event-stream;q=1", + ], +) +def test_json_response_incorrect_accept_header( + json_response_server: None, json_server_url: str, accept_header: str +): """Test that json_response servers reject requests with incorrect Accept header.""" mcp_url = f"{json_server_url}/mcp" - # Test with only text/event-stream (wrong for JSON server) response = requests.post( mcp_url, headers={ - "Accept": "text/event-stream", + "Accept": accept_header, "Content-Type": "application/json", }, json=INIT_REQUEST,