Skip to content

Commit 2af9e30

Browse files
authored
Give each SSE response its own header list (#3726)
1 parent 9f66f37 commit 2af9e30

2 files changed

Lines changed: 45 additions & 12 deletions

File tree

‎src/mcp/server/_streamable_http_modern.py‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,6 @@
7777
logger = logging.getLogger(__name__)
7878

7979

80-
_OK_STATUS = 200
81-
82-
8380
@dataclass
8481
class _SingleExchangeDispatchContext:
8582
"""`DispatchContext` for one inbound HTTP request.
@@ -146,13 +143,6 @@ async def _to_jsonrpc_response(
146143
_SSE_PING_INTERVAL: float = 15.0
147144
"""Seconds between SSE comment-line keepalives once `text/event-stream` has committed."""
148145

149-
_SSE_HEADERS: Final[list[tuple[bytes, bytes]]] = [
150-
(b"content-type", b"text/event-stream"),
151-
(b"cache-control", b"no-cache, no-transform"),
152-
(b"connection", b"keep-alive"),
153-
(b"x-accel-buffering", b"no"),
154-
]
155-
156146

157147
def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> bytes:
158148
"""Serialise a JSON-RPC message as one SSE `event: message` frame.
@@ -188,7 +178,7 @@ async def _write(
188178
send: Send,
189179
) -> None:
190180
"""Serialise a JSON-RPC reply with the table-mapped HTTP status."""
191-
status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, _OK_STATUS) if isinstance(msg, JSONRPCError) else _OK_STATUS
181+
status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, 200) if isinstance(msg, JSONRPCError) else 200
192182
body = msg.model_dump(mode="json", by_alias=True, exclude_none=True)
193183
if isinstance(msg, JSONRPCError) and msg.id is None:
194184
# JSON-RPC requires `id: null` to appear on the wire when the request
@@ -512,7 +502,18 @@ async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None:
512502
# First notification arrived, or the deferral window elapsed: commit
513503
# `text/event-stream` and start pinging so a proxy idle-read timeout
514504
# cannot close the stream (which on this path cancels the handler).
515-
await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS})
505+
await send(
506+
{
507+
"type": "http.response.start",
508+
"status": 200,
509+
"headers": [
510+
(b"content-type", b"text/event-stream"),
511+
(b"cache-control", b"no-cache, no-transform"),
512+
(b"connection", b"keep-alive"),
513+
(b"x-accel-buffering", b"no"),
514+
],
515+
}
516+
)
516517
while not done:
517518
await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True})
518519
event = None

‎tests/server/test_streamable_http_modern.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,38 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
500500
assert events[2]["result"]["tools"] == []
501501

502502

503+
async def test_sse_mode_headers_are_not_shared_across_responses() -> None:
504+
"""SSE mode: each response gets its own header list, so middleware appending in place
505+
cannot leak headers into later responses."""
506+
507+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
508+
await ctx.session.report_progress(1.0)
509+
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
510+
511+
server = Server("test", on_list_tools=list_tools)
512+
513+
async def app(scope: Scope, receive: Receive, send: Send) -> None:
514+
async def append_header(message: Message) -> None:
515+
if message["type"] == "http.response.start":
516+
message["headers"].append((b"x-id", b"abc"))
517+
await send(message)
518+
519+
async with server.lifespan(server) as lifespan_state:
520+
await handle_modern_request(server, None, False, lifespan_state, scope, receive, append_header)
521+
522+
async with httpx2.AsyncClient(
523+
transport=StreamingASGITransport(app),
524+
base_url="http://testserver",
525+
headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, "accept": "application/json, text/event-stream"},
526+
) as http:
527+
for _ in range(2):
528+
response = await http.post(
529+
"/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"}
530+
)
531+
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
532+
assert response.headers.get_list("x-id") == ["abc"]
533+
534+
503535
@pytest.mark.parametrize(
504536
"anyio_backend",
505537
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],

0 commit comments

Comments
 (0)