Skip to content

Modern Streamable HTTP JSON response mode does not observe client disconnects #3762

Open

Description

@DMomplaisir

Scope clarification

This issue now tracks disconnect handling for an in-flight json_response=True request on the modern, per-request Streamable HTTP transport. The original report combined cancellation with notification delivery; those need separate decisions.

Observed behavior

In the original reproduction with Python SDK 2.3.0 and FastMCP slim 4.1.3, closing the POST while a tool was waiting did not cancel the handler. The same modern request using the SSE response path observed cancellation.

Current source in src/mcp/server/_streamable_http_modern.py awaits serve_one(...) directly in the JSON-response branch. That branch returns before the sibling watch_disconnect task used by the SSE path is started.

Reproduction and desired regression coverage

Use the modern protocol revision 2026-07-28 and a valid tools/call request with its required metadata/headers. Configure json_response=True. Have an async tool signal that it has started, wait, and record cancellation and cleanup. Close the client POST before the tool completes.

The modern JSON path should observe the disconnect and cancel the in-flight work with cleanup. Normal completion must still return one application/json terminal response. subscriptions/listen must keep its dedicated SSE path.

The modern Streamable HTTP cancellation rules explicitly describe closing an SSE response stream as cancellation. Applying disconnect handling to the JSON branch is the SDK consistency question here; this report does not claim the specification explicitly spells out JSON-mode cancellation.

Notification semantics are separate

test_json_mode_drops_progress already pins progress as a no-op in JSON mode. The original expectation that offering SSE in Accept should override an explicitly configured json_response=True is withdrawn. Notification diagnostics/documentation can be discussed separately without blocking a cancellation fix.

Legacy transport is a separate compatibility question

Do not apply the modern disconnect behavior to legacy transports solely for symmetry: 2025-11-25 says disconnection should not be interpreted as cancellation and describes an explicit cancellation notification instead.

AI disclosure: Codex reviewed current source and protocol text and drafted this scope clarification. No SDK patch or new transport test was run in this follow-up.

Activity

  1. changed the title [-]Streamable HTTP JSON response mode does not cancel handler work on client disconnect[/-] [+]Streamable HTTP JSON response mode drops notifications and ignores client disconnects[/+] on Oct 25, 2026
  2. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Oct 25, 2026
  3. PRINCE2-AI commented on Oct 26, 2026

    @PRINCE2-AI

    Confirmed on main @ a525a3d by reading both transports. On the modern path the notification is discarded before it is ever serialized, which is a sharper failure than losing it on the wire.

    Where it happens

    • _streamable_http_modern.py:455 — the json_response and req.method != "subscriptions/listen" shortcut calls serve_one(...) and writes the result directly (:462).
    • dctx.sink = send_ch is assigned at :466, inside the branch that the shortcut returns before.
    • notify() (:107) returns immediately when self.sink is None:
    async def notify(self, method, params, opts=None):
        if self.sink is None:
            return

    So in JSON mode ctx.report_progress() and ctx.info() are a silent no-op, not a failed send. The handler cannot tell that its notifications have nowhere to go — that is the part I would want pinned by a test regardless of which fix you choose.

    Disconnect: watch_disconnect is started at :485, also inside the SSE branch. In JSON mode nothing reads http.disconnect, so cancel_requested never fires and the tool runs to completion after the client is gone.

    Two notes on the expected behavior

    1. The deferral machinery that would implement it already exists a few lines below. In the SSE branch, if no event arrives within _SSE_PING_INTERVAL the response is written as application/json anyway (done → _write(result[0], ...)). That is already "stream if something is emitted, JSON otherwise", so for a client sending Accept: application/json, text/event-stream the shortcut could fall through to that path and only apply when the client did not offer SSE.
    2. That fallback changes the content type for any request that emits a notification, which is observable — a client that configured json_response=True asked for JSON. The legacy transport treats the drop as intentional and says so at streamable_http.py:656-658 ("anything else scoped to the request has no wire in JSON-response mode"), so I would rather ask than assume: is the intended fix the SSE fallback, or making the JSON-mode no-op explicit (warn/log, or reject the request when the handler emits a notification)?

    The disconnect half looks unambiguous to me: the JSON branch can wrap the handler in the same task group with watch_disconnect and still write the single JSON body.

    Happy to put a patch together once you say which direction you want on the notification question. I can cover four cases — JSON-only client, JSON+SSE client with no notifications, JSON+SSE client with progress/log, disconnect mid-call — on both the modern and the legacy transport.

  4. duongynhi000005-oss commented on Oct 26, 2026

    @duongynhi000005-oss

    Technical Root Cause & Proposal

    1. Client Disconnect Handling

    In src/mcp/server/_streamable_http_modern.py (handle_modern_request), the SSE response path starts watch_disconnect as a sibling task in a task group to monitor http.disconnect events from ASGI receive(). In contrast, when json_response=True, the handler serve_one was called directly without a sibling disconnect watcher:

    if json_response and req.method != "subscriptions/listen":
        msg = await _to_jsonrpc_response(
            req.id, serve_one(...)
        )
        await _write(msg, scope, receive, send)
        return

    Because receive() is not read while serve_one is executing, the ASGI server never delivers http.disconnect to the handler, and closing the connection leaves the tool/handler running to completion.

    2. Proposed Fix for Disconnect

    We can wrap serve_one inside an anyio.CancelScope alongside watch_disconnect. If http.disconnect arrives, watch_disconnect cancels the handler scope, triggering standard cancellation and ensuring connection.exit_stack teardown executes cleanly. On normal completion, cancelling the watcher allows the task group to exit and the single application/json payload is returned as expected:

    result: list[JSONRPCResponse | JSONRPCError] = []
    
    async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None:
        while (await receive()).get("type") != "http.disconnect":
            pass
        cancel_scope.cancel()
    
    with anyio.CancelScope() as handler_scope:
        async with anyio.create_task_group() as tg:
            tg.start_soon(watch_disconnect, handler_scope)
            try:
                result.append(
                    await _to_jsonrpc_response(
                        req.id,
                        serve_one(
                            app,
                            dctx,
                            req.method,
                            req.params,
                            connection=connection,
                            lifespan_state=lifespan_state,
                        ),
                    )
                )
            finally:
                tg.cancel_scope.cancel()
    
    if result:
        await _write(result[0], scope, receive, send)
    return

    3. Notifications Visibility

    Per test_json_mode_drops_progress, JSON mode purposefully emits a single terminal JSON response without streaming notifications. In _SingleExchangeDispatchContext.notify(), logging dropped notifications at debug level (logger.debug("dropped %s: no response stream (JSON-response mode)", method)) provides clear diagnostic visibility without altering the agreed-upon single-response contract.

    4. Verification

    The fix and a new regression test (test_json_mode_disconnect_cancels_handler_and_runs_exit_stack) have been tested on our fork with 71/71 tests passing cleanly (uv run pytest tests/server/test_streamable_http_modern.py). We are ready to open a PR whenever maintainers would like us to submit it.

  5. PRINCE2-AI commented on Oct 26, 2026

    @PRINCE2-AI

    One additional data point for the legacy side: this affects both stackable HTTP transports, and streamable_http.py currently has no http.disconnect handling at all (grep -rn http.disconnect src/mcp/server/ matches only _streamable_http_modern.py:479), so the disconnect half reproduces there as well — the JSON branch awaits a single receive() with no cancellation watcher.

    On notifications, test_json_mode_drops_progress (tests/server/test_streamable_http_modern.py:620) already pins the current behaviour as SDK-defined for the modern transport, so the open question is whether legacy should get the same no-op documented explicitly rather than changed.

  6. Ben-Home commented on Oct 27, 2026

    @Ben-Home

    The is_json_response_enabled branch is doing two unrelated things, and they deserve separate fixes because only one of them is a negotiation problem.

    The notification loss is a negotiation problem. At response-header time the server cannot know whether a handler will emit notifications/progress, so choosing JSON because json_response=True is set makes the mode decision before the evidence exists. Since the client offered text/event-stream in Accept, the honest options are to honour SSE whenever the server has any notification-emitting handler reachable from the request, or to reject json_response=True at registration when such a handler exists. Silently dropping is the one option that produces a client that looks successful while losing protocol messages.

    The cancellation loss is not a negotiation problem at all. The disconnect watcher in the streaming path is what flips the cancellation flag, and in JSON mode nothing observes the socket, so the handler runs to completion for a client that has gone. In a server that bills per token or holds a lock, that is the expensive half of this bug. A background task that polls the request's disconnect state and cancels the handler task would fix it independently of the response mode, and it is worth having even in the streaming path as a second observer.

    A test that separates the two: assert the handler's cancellation flag flips within some bound after the client closes the POST with json_response=True, and assert a progressToken request delivers at least one notification. Both fail today, both have different owners in the code.

    One more case worth pinning while you are in there: with json_response=True, a handler that emits notifications/message after the JSON body is flushed has nowhere to put it. If the fix is buffering and extending the body, that is a protocol violation rather than a workaround, so the constraint does need to be explicit somewhere the server author will read it.

  7. PRINCE2-AI commented on Oct 27, 2026

    @PRINCE2-AI

    Two implementation constraints worth adding to the options list, both verified on main @ a525a3d:

    The SSE deferral path is not free. _SSE_PING_INTERVAL = 15.0 (_streamable_http_modern.py:143) with the move_on_after at :489 means status and headers are not committed until the first notification or the window expires. Sending json_response=True traffic through that path to gain the fallback adds up to 15 s of dead air to every call that never emits. And for a client that sent Accept: application/json only, has_sse is False — :396 explicitly allows that combination — so SSE is not available at all there and the choice collapses back to drop-or-reject.

    "Notification after the JSON body is flushed" cannot happen on this path. serve_one is fully awaited at :459-461 before _write at :462, so every handler notification is emitted while sink is None: they are all lost before the body exists, not after the flush. The only way into that window is a detached background task, which has no response channel in either mode. That constraint is documentation, not a branch to write.

    On the disconnect half I agree it is the independent fix. duongynhi's sketch covers the modern handler, and legacy streamable_http.py has no receive() watcher at all today, so the same wrapper is needed in two places.

  8. changed the title [-]Streamable HTTP JSON response mode drops notifications and ignores client disconnects[/-] [+]Modern Streamable HTTP JSON response mode does not observe client disconnects[/+] on Oct 27, 2026
  9. DMomplaisir commented on Oct 27, 2026

    @DMomplaisir
    Author

    Thanks for separating the two behaviors. I’m narrowing this report to disconnect cancellation on the modern per-request transport, while preserving the single-JSON response contract. test_json_mode_drops_progress already documents progress as a no-op in JSON mode; offering SSE in Accept alone is not a reason to change an explicitly configured response mode.

    The useful regression is a disconnect during an in-flight JSON call: the handler is cancelled and cleanup runs, while normal completion still returns one JSON result. subscriptions/listen must keep its SSE path. Legacy transport behavior needs a separate check against its protocol revision: 2025-11-25 says disconnection should not be treated as cancellation, unlike the modern request-stream rules. Please don’t extend a modern-path fix to legacy solely for symmetry.

    There is already a patch proposal above, so I won’t open a competing PR. Notification diagnostics/documentation can be discussed separately from cancellation.

    AI disclosure: Codex reviewed the current source/specification and drafted this clarification; no SDK patch or new transport tests were run in this pass.

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