Skip to content

Streamable HTTP notification triggers duplicate response after HTTP 202 #3631

Description

@lecton-apptio

Initial Checks

Release line

2.x (current stable)

Description

Summary

MCP 2.1.1 can send HTTP 202 for a JSON-RPC notification and then raise ClosedResourceError when writing the notification to a closed internal session writer.

The outer error handler then attempts to send a second HTTP error response after the 202 response has already started. This can result in an unhandled ExceptionGroup or duplicate-response error.

Environment

  • MCP: 2.1.1
  • FastMCP: 4.0.3
  • Python: 3.11
  • Transport: Streamable HTTP
  • Notification: notifications/initialized

Expected behavior

Once MCP has sent HTTP 202 for a notification, a closed internal session writer should not trigger:

  • A second HTTP response.
  • An unhandled exception.
  • A second write attempt to the closed writer.

Actual behavior

The notification path currently:

  1. Sends HTTP 202 Accepted.
  2. Calls writer.send(session_message).
  3. Raises ClosedResourceError because the writer is closed.
  4. Enters the broad POST error handler.
  5. Attempts to send an HTTP 500 response after the 202 response has already started.
  6. Attempts another write through the closed writer.

This can result in an unhandled ExceptionGroup or duplicate-response failure.

Minimal reproduction

  1. Create a StreamableHTTPServerTransport with JSON response mode enabled.
  2. Call transport.connect().
  3. Close transport._read_stream_writer.
  4. Send a JSON-RPC notifications/initialized POST request through transport.handle_request with ASGI exceptions enabled.
  5. Observe the unhandled exception after the initial HTTP 202 response has started.

Evidence

The closed writer failure originates in:

mcp/shared/_context_streams.py:37

The notification handler sends HTTP 202 before attempting to write the notification to the internal session stream.

Related issues

This is related to the closed-writer handling discussed in #2064, but the failure occurs on the MCP v2 notification path.

Temporary workaround

I added a version-specific safeguard that:

  • Preserves the original HTTP 202 response.
  • Prevents the invalid secondary HTTP response.
  • Prevents the final ClosedResourceError from escaping.

The SDK still emits the initial Error handling POST request log because that logging occurs before the workaround can intercept the secondary failure.

Example Code

Install the reproduction dependencies:



python -m pip install "mcp==2.1.1" "httpx==0.28.1"


Run this script:



import asyncio

import httpx
from mcp.server.streamable_http import StreamableHTTPServerTransport

async def main() -> None:
    transport = StreamableHTTPServerTransport(
        mcp_session_id=None,
        is_json_response_enabled=True,
    )

    async with transport.connect():
        assert transport._read_stream_writer is not None

        # Simulate a terminated MCP session after the transport connects.
        await transport._read_stream_writer.aclose()

        async with httpx.AsyncClient(
            transport=httpx.ASGITransport(
                app=transport.handle_request,
                raise_app_exceptions=True,
            ),
            base_url="http://testserver",
        ) as client:
            response = await client.post(
                "/mcp",
                headers={
                    "accept": "application/json",
                    "content-type": "application/json",
                },
                json={
                    "jsonrpc": "2.0",
                    "method": "notifications/initialized",
                    "params": {},
                },
            )

    print(response.status_code)

asyncio.run(main())


Actual Result
The notification handler first sends `HTTP 202 Accepted`.

It then calls:


await writer.send(session_message)


The closed writer raises:


anyio.ClosedResourceError


The outer error handler then attempts a second HTTP error response after the `202` response started. With `raise_app_exceptions=True`, the reproduction exits nonzero with an `ExceptionGroup` that contains the closed-writer failure and duplicate-response error.

Relevant trace locations:

mcp/server/streamable_http.py
mcp/shared/_context_streams.py:37
anyio/streams/memory.py

Python & MCP Python SDK

Python: 3.11.14
MCP Python SDK: 2.1.1
FastMCP: 4.0.3 in the production incident container
Transport: Streamable HTTP

Activity

  1. added
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Oct 2, 2026
  2. premanand8800 commented on Oct 3, 2026

    @premanand8800

    I tried the repro against current main (2118f14) and it fails the same way there.

    From what I can tell the problem is the order of things in the notification branch of _handle_post_request. It sends the 202 first and only then calls writer.send(session_message). When the session stream is already closed that send raises ClosedResourceError, and the catch all except Exception below it tries to send a 500 even though the 202 has already gone out. It then writes the error into the same closed stream again, which fails a second time.

    The fix I tried is small: wrap that one writer.send in a try/except for anyio.ClosedResourceError and anyio.BrokenResourceError and just log at debug level. That's what _streamable_http_modern.py already does in notify() when its stream is gone, so it stays consistent with the rest of the SDK. Since the 202 is already out, there isn't anything left to tell the client.

    I also turned the repro into a test in tests/shared/test_streamable_http.py that checks we get exactly one 202. It fails on main with the ExceptionGroup and passes with the change, and the full suite still passes with coverage at 100%. The branch is here if it helps: https://github.com/premanand8800/python-sdk/tree/fix/notification-202-closed-session

    @lecton-apptio you opened this, so if you'd rather fix it yourself, go ahead. I'm happy either way.

    Full disclosure: I used Claude Code to help dig into this and test it, but I went through the change myself and can answer questions about it.

  3. rupak-eng commented on Oct 4, 2026

    @rupak-eng

    Reproduced on current main (2118f14f) with the issue's harness: after the 202 response starts, writer.send(session_message) raises ClosedResourceError and the generic POST error handler then attempts a second HTTP response, which fails (AssertionError from the ASGI transport: response already started) — so the client-facing failure is the duplicate response, not just the closed writer.

    Approach taken locally (additive, no public-API change): in the notification branch of _handle_post_request, catch ClosedResourceError from the writer.send after the 202 is sent and drop the message with a debug log instead of falling into the generic error handler. That handler both attempts the second response and retries writer.send(Exception(err)), so catching at the site covers all three expectations in the issue (no second response, no unhandled exception, no second write to the closed writer). The normal path (open writer) is untouched.

    A regression test (notification POST with a closed session writer asserts 202 and no escaping exception; it fails on unpatched main) plus the repo's checks (ruff check, ruff format --check, pyright clean; existing server streamable suites green) pass. Fix is implemented and tested in a branch on my fork — happy to open a PR if this is assigned or tagged help wanted.

    (Disclosing: prepared with AI assistance; I ran the repro and tests myself and can explain the change.)

  4. Varshith-Kali commented on Oct 7, 2026

    @Varshith-Kali

    I've reproduced this on current main as well — same pair of exceptions as described above: the notification branch sends the 202, then writer.send(session_message) raises ClosedResourceError once the session tears down, and the outer handler attempts a second response on the completed ASGI scope. I have a tested fix for this (regression tests included) from the duplicate #3641 — happy to retarget the PR here. Could you assign #3631 to me?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions