Skip to content

Commit ba5e637

Browse files
committed
Give each modern SSE response its own header list
handle_modern_request passed the module-level _SSE_HEADERS list to http.response.start. ASGI middleware that appends to message["headers"] in place mutated it, so headers accumulated and leaked across all later SSE responses in the process. Pass a copy instead.
1 parent 754254c commit ba5e637

2 files changed

Lines changed: 37 additions & 1 deletion

File tree

‎src/mcp/server/_streamable_http_modern.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None:
512512
# First notification arrived, or the deferral window elapsed: commit
513513
# `text/event-stream` and start pinging so a proxy idle-read timeout
514514
# cannot close the stream (which on this path cancels the handler).
515-
await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS})
515+
await send({"type": "http.response.start", "status": _OK_STATUS, "headers": list(_SSE_HEADERS)})
516516
while not done:
517517
await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True})
518518
event = None

‎tests/server/test_streamable_http_modern.py‎

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

502502

503+
async def test_sse_mode_gives_each_response_its_own_header_list() -> None:
504+
"""Middleware that appends to `message["headers"]` in place must not leak into later SSE responses."""
505+
506+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
507+
await ctx.session.report_progress(1.0, total=3.0)
508+
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
509+
510+
server = Server("test", on_list_tools=list_tools)
511+
512+
async def app(scope: Scope, receive: Receive, send: Send) -> None:
513+
async def appending_send(message: Message) -> None:
514+
if message["type"] == "http.response.start":
515+
message["headers"].append((b"x-correlation-id", b"abc"))
516+
await send(message)
517+
518+
async with server.lifespan(server) as lifespan_state:
519+
await handle_modern_request(server, None, False, lifespan_state, scope, receive, appending_send)
520+
521+
async with httpx2.AsyncClient(
522+
transport=StreamingASGITransport(app),
523+
base_url="http://testserver",
524+
headers={
525+
MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION,
526+
"content-type": "application/json",
527+
"accept": "application/json, text/event-stream",
528+
},
529+
) as http:
530+
with anyio.fail_after(5):
531+
for token in ("tok-1", "tok-2"):
532+
response = await http.post(
533+
"/mcp", json=_list_tools_body_with_token(token), headers={MCP_METHOD_HEADER: "tools/list"}
534+
)
535+
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
536+
assert response.headers.get_list("x-correlation-id") == ["abc"]
537+
538+
503539
@pytest.mark.parametrize(
504540
"anyio_backend",
505541
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],

0 commit comments

Comments
 (0)