From 1fce2fdc6b83dc1de8bf6716752912303a2de69b Mon Sep 17 00:00:00 2001 From: Varun Sharma Date: Mon, 2 Mar 2026 17:16:17 +0530 Subject: [PATCH 1/5] fix: clean up SSE session on client disconnect Remove session from _read_stream_writers when a client disconnects from the SSE endpoint. Previously, stale session entries accumulated indefinitely, causing issues after server reloads where old session IDs would still be found in the dict (but with closed streams). Fixes #423 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/mcp/server/sse.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 9007230ce..9dcee67f7 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -186,6 +186,7 @@ async def response_wrapper(scope: Scope, receive: Receive, send: Send): ) await read_stream_writer.aclose() await write_stream_reader.aclose() + self._read_stream_writers.pop(session_id, None) logging.debug(f"Client session disconnected {session_id}") logger.debug("Starting SSE response task") From c152ca5d24f7a38c58b93e1740772b536bcfde40 Mon Sep 17 00:00:00 2001 From: Varun Sharma Date: Mon, 2 Mar 2026 17:56:23 +0530 Subject: [PATCH 2/5] Add regression test for SSE session cleanup on disconnect Add test_sse_session_cleanup_on_disconnect that verifies: - After a client disconnects, the stale session is removed from _read_stream_writers - POST requests to the disconnected session return 404 (not 202) This provides evidence that the fix for #423 works correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/shared/test_sse.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 7b2bc0a13..b5dca4dc7 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -611,3 +611,41 @@ async def mock_aiter_sse() -> AsyncGenerator[ServerSentEvent, None]: assert not isinstance(msg, Exception) assert isinstance(msg.message, types.JSONRPCResponse) assert msg.message.id == 1 + + +@pytest.mark.anyio +async def test_sse_session_cleanup_on_disconnect(server: None, server_url: str) -> None: + """Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/423 + + When a client disconnects, the server should remove the session from + _read_stream_writers. Without this cleanup, stale sessions accumulate and + POST requests to disconnected sessions are incorrectly accepted instead + of returning 404. + """ + captured_session_id: str | None = None + + def on_session_created(session_id: str) -> None: + nonlocal captured_session_id + captured_session_id = session_id + + # Connect a client session, then disconnect + async with sse_client(server_url + "/sse", on_session_created=on_session_created) as streams: + async with ClientSession(*streams) as session: + await session.initialize() + + assert captured_session_id is not None + + # After disconnect, POST to the stale session should return 404 + # (not 202 as it did before the fix) + async with httpx.AsyncClient() as client: + with anyio.fail_after(5): + while True: + response = await client.post( + f"{server_url}/messages/?session_id={captured_session_id}", + json={"jsonrpc": "2.0", "method": "ping", "id": 99}, + headers={"Content-Type": "application/json"}, + ) + if response.status_code == 404: + break + await anyio.sleep(0.1) + assert response.status_code == 404 From 3d28ad1df59c32ad13af040aa4d2ddc3ead1d5c6 Mon Sep 17 00:00:00 2001 From: Varun Sharma Date: Mon, 2 Mar 2026 18:01:34 +0530 Subject: [PATCH 3/5] Fix coverage: simplify test by removing unnecessary retry loop Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/shared/test_sse.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index b5dca4dc7..43936ce03 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -638,14 +638,9 @@ def on_session_created(session_id: str) -> None: # After disconnect, POST to the stale session should return 404 # (not 202 as it did before the fix) async with httpx.AsyncClient() as client: - with anyio.fail_after(5): - while True: - response = await client.post( - f"{server_url}/messages/?session_id={captured_session_id}", - json={"jsonrpc": "2.0", "method": "ping", "id": 99}, - headers={"Content-Type": "application/json"}, - ) - if response.status_code == 404: - break - await anyio.sleep(0.1) + response = await client.post( + f"{server_url}/messages/?session_id={captured_session_id}", + json={"jsonrpc": "2.0", "method": "ping", "id": 99}, + headers={"Content-Type": "application/json"}, + ) assert response.status_code == 404 From f0057a5468e702ac53ccc7e96141a63b4cc11689 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:23:38 +0000 Subject: [PATCH 4/5] Update test docstring to reference correct issue (#1227) The fix addresses stale session entries in _read_stream_writers after disconnect (202 + ClosedResourceError symptom from #1227), not the EventSource auto-reconnect scenario from #423 which is a client-side concern already mitigated by #822 and #1478. --- tests/shared/test_sse.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 43936ce03..1a0d56672 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -615,12 +615,12 @@ async def mock_aiter_sse() -> AsyncGenerator[ServerSentEvent, None]: @pytest.mark.anyio async def test_sse_session_cleanup_on_disconnect(server: None, server_url: str) -> None: - """Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/423 + """Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/1227 When a client disconnects, the server should remove the session from _read_stream_writers. Without this cleanup, stale sessions accumulate and - POST requests to disconnected sessions are incorrectly accepted instead - of returning 404. + POST requests to disconnected sessions return 202 Accepted followed by a + ClosedResourceError when the server tries to write to the dead stream. """ captured_session_id: str | None = None From 25d75bb35c6f0e2a4396a4243d56415a4b668910 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:36:33 +0000 Subject: [PATCH 5/5] Use list.append for session_id capture to fix 3.11 coverage gap On Python 3.11 with coverage 7.10.7 (lowest-direct), plain statements immediately after async with sse_client(...) exits are not traced reliably due to cancellation in __aexit__ interacting with 3.11's zero-cost exception bytecode. Entering a new async with block does generate a line event, so removing the intermediate assert and indexing the list directly inside the httpx block sidesteps the tracer gap without needing a pragma. --- tests/shared/test_sse.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 1a0d56672..bfbecc0c8 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -622,24 +622,18 @@ async def test_sse_session_cleanup_on_disconnect(server: None, server_url: str) POST requests to disconnected sessions return 202 Accepted followed by a ClosedResourceError when the server tries to write to the dead stream. """ - captured_session_id: str | None = None - - def on_session_created(session_id: str) -> None: - nonlocal captured_session_id - captured_session_id = session_id + captured: list[str] = [] # Connect a client session, then disconnect - async with sse_client(server_url + "/sse", on_session_created=on_session_created) as streams: + async with sse_client(server_url + "/sse", on_session_created=captured.append) as streams: async with ClientSession(*streams) as session: await session.initialize() - assert captured_session_id is not None - # After disconnect, POST to the stale session should return 404 # (not 202 as it did before the fix) async with httpx.AsyncClient() as client: response = await client.post( - f"{server_url}/messages/?session_id={captured_session_id}", + f"{server_url}/messages/?session_id={captured[0]}", json={"jsonrpc": "2.0", "method": "ping", "id": 99}, headers={"Content-Type": "application/json"}, )