diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index aa99e7c88..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) @@ -426,16 +444,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 +502,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 +555,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..c23910d5d 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -608,12 +608,60 @@ 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"), + ("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( + 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", + "application/json;q=0, text/event-stream;q=0", ], ) def test_accept_header_incompatible(basic_server: None, basic_server_url: str, accept_header: str): @@ -888,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,