From c29998530a5f9885f52cab1a38933f862033ddae Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 17 Oct 2026 15:43:14 +0200 Subject: [PATCH 1/6] Give each SSE response its own header list --- src/mcp/server/_streamable_http_modern.py | 2 +- tests/server/test_streamable_http_modern.py | 33 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index 1a19061fcd..65d9dc3ce0 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -512,7 +512,7 @@ async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None: # First notification arrived, or the deferral window elapsed: commit # `text/event-stream` and start pinging so a proxy idle-read timeout # cannot close the stream (which on this path cancels the handler). - await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS}) + await send({"type": "http.response.start", "status": _OK_STATUS, "headers": list(_SSE_HEADERS)}) while not done: await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True}) event = None diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 5d473550b9..19039b6d01 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -500,6 +500,39 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | assert events[2]["result"]["tools"] == [] +async def test_sse_mode_headers_are_not_shared_across_responses() -> None: + """SSE mode: each response gets its own header list, so middleware appending in place + cannot leak headers into later responses.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + await ctx.session.report_progress(1.0) + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + server = Server("test", on_list_tools=list_tools) + + async def app(scope: Scope, receive: Receive, send: Send) -> None: + async def append_header(message: Message) -> None: + if message["type"] == "http.response.start": + message["headers"].append((b"x-id", b"abc")) + await send(message) + + async with server.lifespan(server) as lifespan_state: + await handle_modern_request(server, None, False, lifespan_state, scope, receive, append_header) + + async with httpx2.AsyncClient( + transport=StreamingASGITransport(app), + base_url="http://testserver", + headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, "accept": "application/json, text/event-stream"}, + ) as http: + for _ in range(2): + with anyio.fail_after(5): + response = await http.post( + "/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"} + ) + assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream" + assert response.headers.get_list("x-id") == ["abc"] + + @pytest.mark.parametrize( "anyio_backend", [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], From 6c571761e6da61dddcca489ce0c1865b8de35c90 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 17 Oct 2026 15:45:51 +0200 Subject: [PATCH 2/6] Drop the timeout guard from the SSE header test --- tests/server/test_streamable_http_modern.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 19039b6d01..a539720d58 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -525,10 +525,9 @@ async def append_header(message: Message) -> None: headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, "accept": "application/json, text/event-stream"}, ) as http: for _ in range(2): - with anyio.fail_after(5): - response = await http.post( - "/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"} - ) + response = await http.post( + "/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"} + ) assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream" assert response.headers.get_list("x-id") == ["abc"] From f2c826c8c36576742527b81bca311c9c09b7c5cd Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 17 Oct 2026 15:45:51 +0200 Subject: [PATCH 3/6] Remove the fail_after note from AGENTS.md --- AGENTS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4a94783bea..be94a06219 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,6 @@ assigned to their author are closed automatically. - Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test - For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()` - Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts) -- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs - Pytest is configured with `filterwarnings = ["error"]`, so warnings fail tests. Don't silence warnings from your own code; fix the underlying cause. Scoped `ignore::` entries for upstream libraries are acceptable in From e213d78c5d6f0983f09f9f66e1ca1402401c40d2 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 17 Oct 2026 15:46:48 +0200 Subject: [PATCH 4/6] Revert "Remove the fail_after note from AGENTS.md" This reverts commit f2c826c8c36576742527b81bca311c9c09b7c5cd. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index be94a06219..4a94783bea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ assigned to their author are closed automatically. - Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test - For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()` - Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts) +- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs - Pytest is configured with `filterwarnings = ["error"]`, so warnings fail tests. Don't silence warnings from your own code; fix the underlying cause. Scoped `ignore::` entries for upstream libraries are acceptable in From 9194e0c33054a8aea93320e67b59d89aa87cecb3 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 17 Oct 2026 15:50:34 +0200 Subject: [PATCH 5/6] Inline the SSE response headers --- src/mcp/server/_streamable_http_modern.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index 65d9dc3ce0..cc81ca20a4 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -146,13 +146,6 @@ async def _to_jsonrpc_response( _SSE_PING_INTERVAL: float = 15.0 """Seconds between SSE comment-line keepalives once `text/event-stream` has committed.""" -_SSE_HEADERS: Final[list[tuple[bytes, bytes]]] = [ - (b"content-type", b"text/event-stream"), - (b"cache-control", b"no-cache, no-transform"), - (b"connection", b"keep-alive"), - (b"x-accel-buffering", b"no"), -] - def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> bytes: """Serialise a JSON-RPC message as one SSE `event: message` frame. @@ -512,7 +505,18 @@ async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None: # First notification arrived, or the deferral window elapsed: commit # `text/event-stream` and start pinging so a proxy idle-read timeout # cannot close the stream (which on this path cancels the handler). - await send({"type": "http.response.start", "status": _OK_STATUS, "headers": list(_SSE_HEADERS)}) + await send( + { + "type": "http.response.start", + "status": _OK_STATUS, + "headers": [ + (b"content-type", b"text/event-stream"), + (b"cache-control", b"no-cache, no-transform"), + (b"connection", b"keep-alive"), + (b"x-accel-buffering", b"no"), + ], + } + ) while not done: await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True}) event = None From 76d9e34bf38a86b0e06b4f58696cd9390fb992a5 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 17 Oct 2026 15:51:13 +0200 Subject: [PATCH 6/6] Inline the OK status code --- src/mcp/server/_streamable_http_modern.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index cc81ca20a4..ad4257319d 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -77,9 +77,6 @@ logger = logging.getLogger(__name__) -_OK_STATUS = 200 - - @dataclass class _SingleExchangeDispatchContext: """`DispatchContext` for one inbound HTTP request. @@ -181,7 +178,7 @@ async def _write( send: Send, ) -> None: """Serialise a JSON-RPC reply with the table-mapped HTTP status.""" - status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, _OK_STATUS) if isinstance(msg, JSONRPCError) else _OK_STATUS + status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, 200) if isinstance(msg, JSONRPCError) else 200 body = msg.model_dump(mode="json", by_alias=True, exclude_none=True) if isinstance(msg, JSONRPCError) and msg.id is None: # JSON-RPC requires `id: null` to appear on the wire when the request @@ -508,7 +505,7 @@ async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None: await send( { "type": "http.response.start", - "status": _OK_STATUS, + "status": 200, "headers": [ (b"content-type", b"text/event-stream"), (b"cache-control", b"no-cache, no-transform"),