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
{{ message }}
Repository navigation
Modern Streamable HTTP JSON response mode does not observe client disconnects #3762
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.
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.
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
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.
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.[-]Streamable HTTP JSON response mode drops notifications and ignores client disconnects[/-][+]Modern Streamable HTTP JSON response mode does not observe client disconnects[/+]DMomplaisir commentedon Oct 27, 2026
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_progressalready documents progress as a no-op in JSON mode; offering SSE inAcceptalone 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/listenmust 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.