Skip to content

Add SSE response mode to the 2026 streamable-HTTP server entry#3001

Merged
maxisbey merged 6 commits into
mainfrom
modern-sse-response
Jun 26, 2026
Merged

Add SSE response mode to the 2026 streamable-HTTP server entry#3001
maxisbey merged 6 commits into
mainfrom
modern-sse-response

Conversation

@maxisbey

@maxisbey maxisbey commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

The modern (2026-07-28) single-exchange entry could only respond with application/json, so a handler's ctx.report_progress() / request-scoped notifications/message was a no-op on that path. This adds the text/event-stream response mode so those notifications stream before the terminal response.

Part of #2893; tracked under #2891.

Motivation and Context

The transport spec says a server MAY respond to a POST with either application/json or text/event-stream; the SSE stream carries notifications/progress / notifications/message related to the originating request, then the terminal response, then closes. Stream-close is the cancellation signal. The legacy stack already does both modes via json_response: bool; this is the modern entry catching up.

How Has This Been Tested?

  • New unit tests in tests/server/test_streamable_http_modern.py: SSE streams progress + log in order before the result; JSON mode drops; handler error is a 200 + JSONRPCError event; no-token → progress no-op; mode-aware 406 on Accept; */* wildcard; late notify after stream-close drops; client disconnect cancels the handler and connection.exit_stack still unwinds.
  • The previously-xfailed [streamable-http-2026-07-28] legs of the interaction-suite progress/logging requirements now pass.
  • The streaming example story's http-asgi:modern leg now passes.
  • Burns the tools-call-with-progress server scenario from expected-failures.2026-07-28.yml.

Breaking Changes

None for users — the manager's existing json_response flag governs the modern path too (default False = SSE, same as legacy). The modern entry now validates Accept (mode-aware: JSON mode requires application/json; SSE mode requires both), so a client that omitted Accept entirely now gets 406 — but the spec already requires clients to send both.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Shape: _SingleExchangeDispatchContext gains progress_token + sink: MemoryObjectSendStream | None. notify() writes an SSE-framed event to the sink (catching ClosedResourceError/BrokenResourceError → debug-log and drop, mirroring _JSONRPCDispatchContext); JSON mode has sink=None so it returns. progress() reads the token and calls notify(). send_raw_request still raises NoBackChannelError — SSE is notify-only; sampling/elicitation/list-roots stay on the MRTR path.

SSE write path — deferred start: the handler runs as a sibling task while the parent waits for the first event from a rendezvous (buffer=0) memory stream. If the handler completes (or raises) without emitting, the result is written as application/json with the ERROR_CODE_HTTP_STATUS-mapped status — so the spec's 404/400 MUSTs hold for kernel-dispatch errors (METHOD_NOT_FOUND, missing-capability) even with json_response=False. text/event-stream headers go out only once a notification has actually arrived; from there a watch_disconnect task in the same group cancels serve_one on http.disconnect (stream-close = cancellation), and a move_on_after(15) around the receive loop emits : ping comment-line keepalives between events, matching the legacy paths' interval. SSE frames are written directly via ASGI send rather than EventSourceResponse because ESR sends headers on __call__ and its internal task group can't adopt an already-running handler. async with send_ch: guarantees the stream closes on every exit path.

Shared helpers: check_accept_headers() hoisted to module level in streamable_http.py (the legacy method body never read self); progress_token_from_params() extracted to shared/jsonrpc_dispatcher.py with the existing bool-guard. Both entries / both dispatch contexts call them.

Coverage notes: the streaming story's modern-HTTP cancellation leg now exercises two pre-existing client-side except Exception blocks in client/streamable_http.py (forwarding an SSE event to a read_stream_writer the cancel already closed). Promoted from no coverlax no cover with an in-file comment; the logger.exception("Error parsing SSE message") there is misleading (it's BrokenResourceError, not a parse failure) — worth a follow-up but out of scope here.

AI Disclaimer

Comment thread src/mcp/server/streamable_http.py Outdated
@maxisbey maxisbey marked this pull request as ready for review June 26, 2026 14:57
The modern (2026-07-28) single-exchange entry could only respond with
application/json, so a handler's ctx.report_progress() / ctx.log() was a
no-op on that path. This adds the text/event-stream response mode: the
handler runs as a sibling task inside an EventSourceResponse via
data_sender_callable=, request-scoped notifications stream as SSE events
before the terminal JSON-RPC response, and client disconnect cancels the
handler (stream-close = cancellation per the transport spec).

The existing json_response: bool on the session manager governs the
modern path too — JSON mode keeps today's drop-notifications behaviour.
Accept validation lands at the same time (mode-aware, matching the
legacy entry; check_accept_headers hoisted to module level so both
entries share it). progress_token_from_params() extracted to shared/
with the bool-guard.

Burns the tools-call-with-progress conformance waiver, the
_MODERN_NOTIFY_DROP KnownFailure rows, and the streaming story's
http-asgi:modern xfail.
@maxisbey maxisbey force-pushed the modern-sse-response branch from 7382d8e to ea346df Compare June 26, 2026 15:03

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found and verified against the latest diff

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/mcp/server/streamable_http.py
Comment thread src/mcp/server/_streamable_http_modern.py
Comment thread src/mcp/server/_streamable_http_modern.py Outdated
maxisbey added 2 commits June 26, 2026 15:27
The previous commit committed text/event-stream headers immediately, so a
METHOD_NOT_FOUND raised inside serve_one reached the wire as HTTP 200 + an
SSE event — breaking the spec MUST that an unimplemented method respond
404. server-stateless conformance failed 7/25 on that.

The handler now runs as a sibling task while the parent waits for the first
event from the rendezvous stream. If the handler completes (or raises)
without emitting, the result is written as application/json with the
ERROR_CODE_HTTP_STATUS-mapped status — same as JSON mode. text/event-stream
headers go out only once a notification has actually arrived; from there a
disconnect-watcher in the same task group cancels serve_one on
http.disconnect. EventSourceResponse is dropped for this path (it sends
headers on __call__ and its task group can't adopt an already-running
handler); SSE frames are written directly via ASGI send.

Also: drop the _check_accept_headers wrapper method (review feedback).
Comment thread src/mcp/server/_streamable_http_modern.py Outdated
The 5820a22 raw-ASGI rewrite dropped the comment-line keepalive that
EventSourceResponse used to provide, so a long-silent handler behind a
proxy idle-read timeout would have its stream closed (which on this path
cancels the handler). The receive loop now emits ': ping' on a 15s
move_on_after between events, matching the legacy paths' interval.
Comment thread src/mcp/server/_streamable_http_modern.py
maxisbey added 2 commits June 26, 2026 16:26
A handler that runs silent past _SSE_PING_INTERVAL now commits
text/event-stream and starts pinging instead of leaving zero bytes on the
wire — so a proxy idle-read timeout can't close the connection (which on
this path cancels the handler) before the result arrives. Kernel-dispatch
errors still get the table-mapped JSON status because they resolve well
within the 15s window.
The two keepalive tests now run on trio's autojumping MockClock instead
of a real 50ms anyio.sleep against a 10ms patched interval. The simulated
2.5s-silent handler takes no wall-clock time, the ping count is asserted
exactly (== 2) instead of by presence, and there is no scheduler-timing
window. Same pattern as test_jsonrpc_dispatcher.py and test_timeouts.py.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any new issues in the latest revision — the keepalive and Cache-Control concerns from my earlier inline comments look properly addressed (bounded first-event wait, ping loop, no-transform header) and are now covered by tests — but the hand-rolled ASGI SSE write path with deferred-start, disconnect-driven cancellation, and status-mapping semantics is complex enough that it warrants a human reviewer's sign-off.

Extended reasoning...

Overview

This PR adds an SSE response mode to the 2026-07-28 single-exchange streamable-HTTP server entry (_streamable_http_modern.py), so request-scoped progress and log notifications stream before the terminal response. It threads json_response through the manager, hoists check_accept_headers to module level in streamable_http.py, extracts progress_token_from_params into shared/jsonrpc_dispatcher.py, burns several conformance/interaction xfails, and adds a substantial unit-test suite for the new path (including trio MockClock keepalive timing tests and a disconnect-cancellation test).

Security risks

Low. The Accept-header validation is a byte-for-byte hoist of the existing parser (the q-value gap noted by another bot is pre-existing, not introduced here). No auth, crypto, or permission logic is touched; the new code only changes how a server's own response is framed on the wire. The notify path serializes JSON-RPC notifications it already constructs, so there is no new injection surface.

Level of scrutiny

High. This is production transport code on the default (json_response=False) path for the modern protocol revision, and the SSE branch is hand-rolled ASGI (deferred response start, a rendezvous memory stream, a sibling-task handler, disconnect-driven cancellation, and move_on_after-based keepalive). Earlier review iterations on this very PR surfaced two real robustness regressions (missing keepalive, unbounded pre-commit silence) that were only caught by careful reasoning about proxy idle timeouts — exactly the kind of subtle concurrency/deployment behavior that deserves a human maintainer's judgment on the final shape, including the deferred-start design tradeoff itself.

Other factors

All prior reviewer feedback (maxisbey's wrapper-removal comment and my inline findings on Cache-Control parity, the missing keepalive ping, and the unbounded first-event wait) has been addressed in follow-up commits with accompanying tests, and the current diff reflects those fixes. Test coverage of the new path is good: ordering, error-before/after-commit, 406 mode-aware Accept handling, wildcard Accept, late-notify drop, silent-handler commit, and disconnect cancellation are all exercised. I found no remaining bugs, but the size and criticality of the change put it outside what I'm willing to shadow-approve.

@maxisbey maxisbey enabled auto-merge (squash) June 26, 2026 17:06
@maxisbey maxisbey merged commit 067f905 into main Jun 26, 2026
34 checks passed
@maxisbey maxisbey deleted the modern-sse-response branch June 26, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants