Skip to content

Streamable HTTP JSON response mode drops notifications and ignores client disconnects #3762

Open

Description

@DMomplaisir

Description

On the modern Streamable HTTP path, a server configured with json_response=True always returns a single JSON body and does not start the SSE writer/disconnect watcher used by the streaming path. As a result, in-flight notifications/progress and notifications/message events are lost, and closing the client connection does not cancel the handler.

This differs from the SSE response path, where those notifications are sent and a disconnect cancels the request. The single-response mode should not silently discard protocol messages when the client offered SSE in Accept.

Reproduction

Run a minimal MCPServer with json_response=True and an async tool that sends a progress notification (include a progressToken in the request), emits a log message, then waits. POST a valid tools/call with Accept: application/json, text/event-stream.

On mcp 2.3.0, the client receives only the final JSON result; no progress or log notification is delivered. If the client closes the POST while the tool waits, the handler completes and its cancellation flag remains false. Repeating with json_response=False delivers the SSE notifications and observes cancellation.

Expected

When the client accepts SSE, return a streaming response when a request or handler needs to send notifications, and use the disconnect watcher to cancel the request. If JSON-only responses cannot carry notifications, the server should expose that constraint clearly rather than silently dropping notifications.

Environment

Python SDK 2.3.0; FastMCP slim 4.1.3. The affected code is mcp/server/streamable_http.py's is_json_response_enabled branch.

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.

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