You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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
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:
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
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.
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.
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:
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:
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.
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.
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.
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.
Activity
[-]Streamable HTTP JSON response mode does not cancel handler work on client disconnect[/-][+]Streamable HTTP JSON response mode drops notifications and ignores client disconnects[/+]PRINCE2-AI commentedon Oct 26, 2026
Confirmed on
main@a525a3dby 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— thejson_response and req.method != "subscriptions/listen"shortcut callsserve_one(...)and writes the result directly (:462).dctx.sink = send_chis assigned at:466, inside the branch that the shortcut returns before.notify()(:107) returns immediately whenself.sink is None:So in JSON mode
ctx.report_progress()andctx.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_disconnectis started at:485, also inside the SSE branch. In JSON mode nothing readshttp.disconnect, socancel_requestednever fires and the tool runs to completion after the client is gone.Two notes on the expected behavior
_SSE_PING_INTERVALthe response is written asapplication/jsonanyway (done→_write(result[0], ...)). That is already "stream if something is emitted, JSON otherwise", so for a client sendingAccept: application/json, text/event-streamthe shortcut could fall through to that path and only apply when the client did not offer SSE.json_response=Trueasked for JSON. The legacy transport treats the drop as intentional and says so atstreamable_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_disconnectand 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.
duongynhi000005-oss commentedon Oct 26, 2026
Technical Root Cause & Proposal
1. Client Disconnect Handling
In
src/mcp/server/_streamable_http_modern.py(handle_modern_request), the SSE response path startswatch_disconnectas a sibling task in a task group to monitorhttp.disconnectevents from ASGIreceive(). In contrast, whenjson_response=True, the handlerserve_onewas called directly without a sibling disconnect watcher:Because
receive()is not read whileserve_oneis executing, the ASGI server never delivershttp.disconnectto the handler, and closing the connection leaves the tool/handler running to completion.2. Proposed Fix for Disconnect
We can wrap
serve_oneinside ananyio.CancelScopealongsidewatch_disconnect. Ifhttp.disconnectarrives,watch_disconnectcancels the handler scope, triggering standard cancellation and ensuringconnection.exit_stackteardown executes cleanly. On normal completion, cancelling the watcher allows the task group to exit and the singleapplication/jsonpayload is returned as expected: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.PRINCE2-AI commentedon Oct 26, 2026
One additional data point for the legacy side: this affects both stackable HTTP transports, and
streamable_http.pycurrently has nohttp.disconnecthandling 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 singlereceive()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.Ben-Home commentedon Oct 27, 2026
The
is_json_response_enabledbranch 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 becausejson_response=Trueis set makes the mode decision before the evidence exists. Since the client offeredtext/event-streaminAccept, the honest options are to honour SSE whenever the server has any notification-emitting handler reachable from the request, or to rejectjson_response=Trueat 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 aprogressTokenrequest 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 emitsnotifications/messageafter 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.PRINCE2-AI commentedon Oct 27, 2026
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 themove_on_afterat:489means status and headers are not committed until the first notification or the window expires. Sendingjson_response=Truetraffic 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 sentAccept: application/jsononly,has_sseis False —:396explicitly 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_oneis fully awaited at:459-461before_writeat:462, so every handler notification is emitted whilesinkis 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.pyhas noreceive()watcher at all today, so the same wrapper is needed in two places.