Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions src/mcp/server/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 7 additions & 7 deletions tests/issues/test_1363_race_condition_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand All @@ -137,34 +137,34 @@ 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:
response = await client.post(
"/",
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:
response = await client.post(
"/",
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
Expand Down
66 changes: 61 additions & 5 deletions tests/shared/test_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
Loading