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

This commit is contained in:
Max 2026-06-26 19:09:08 +02:00 • committed by GitHub
commit 067f90578c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 517 additions and 112 deletions

View file

@ -156,7 +156,10 @@ class StreamableHTTPTransport:
# Otherwise, return False to continue listening
return isinstance(message, JSONRPCResponse | JSONRPCError)
except Exception as exc: # pragma: no cover
# Forwarding to a closed read stream lands here when the caller cancels mid-SSE
# (BrokenResourceError, not a parse failure); coverage is timing-dependent in the
# streaming story's modern HTTP cancellation leg.
except Exception as exc: # pragma: lax no cover
logger.exception("Error parsing SSE message")
if original_request_id is not None:
error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse SSE message: {exc}")
@ -372,7 +375,7 @@ class StreamableHTTPTransport:
await response.aclose()
return # Normal completion, no reconnect needed
except Exception:
logger.debug("SSE stream ended", exc_info=True) # pragma: no cover
logger.debug("SSE stream ended", exc_info=True) # pragma: lax no cover
# Stream ended without response - reconnect if we received an event with ID
if last_event_id is not None: # pragma: no branch

View file

@ -5,9 +5,15 @@ The legacy streamable-HTTP transport is untouched and remains the supported
path for earlier protocol revisions.
A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. This
module handles such a request directly in the ASGI task - no memory streams,
no per-request task group, no `JSONRPCDispatcher`.
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON
mode handles the request directly in the ASGI task. SSE mode runs the handler
as a sibling task and defers committing to `text/event-stream` until the
handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever
comes first: a handler that completes (or raises) within that window without
emitting still gets a JSON response with the table-mapped HTTP status, so
the spec's `404`/`400` MUSTs hold for kernel-dispatch errors; a handler that
runs silent past the window commits SSE so the keepalive ping can keep the
connection open behind a proxy idle-read timeout.
"""
from __future__ import annotations
@ -16,9 +22,10 @@ import json
import logging
from collections.abc import Awaitable, Mapping
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, TypeVar
from typing import TYPE_CHECKING, Any, Final, TypeVar
import anyio
from anyio.streams.memory import MemoryObjectSendStream
from mcp_types import (
INTERNAL_ERROR,
INVALID_REQUEST,
@ -27,8 +34,10 @@ from mcp_types import (
ErrorData,
Implementation,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ProgressToken,
RequestId,
)
from pydantic import BaseModel, ValidationError
@ -38,6 +47,7 @@ from starlette.types import Receive, Scope, Send
from mcp.server.connection import Connection
from mcp.server.runner import serve_one
from mcp.server.streamable_http import check_accept_headers
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError
@ -46,7 +56,7 @@ from mcp.shared.inbound import (
InboundLadderRejection,
classify_inbound_request,
)
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data, progress_token_from_params
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
from mcp.shared.transport_context import TransportContext
@ -66,12 +76,15 @@ class _SingleExchangeDispatchContext:
Structurally satisfies `mcp.shared.dispatcher.DispatchContext`. The
back-channel is closed by construction: a 2026-07-28 server cannot send
requests to the client.
requests to the client. The SSE sink, when present, carries request-scoped
notifications onto this request's response stream.
"""
transport: TransportContext
request_id: RequestId
message_metadata: MessageMetadata
progress_token: ProgressToken | None = None
sink: MemoryObjectSendStream[bytes] | None = None
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
can_send_request: bool = field(default=False, init=False)
@ -84,12 +97,23 @@ class _SingleExchangeDispatchContext:
raise NoBackChannelError(method)
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
# TODO(D-005a): buffer and stream as SSE once the JSON-vs-SSE response mode lands.
return None
if self.sink is None:
return
body = dict(params) if params is not None else None
try:
await self.sink.send(_sse_event(JSONRPCNotification(jsonrpc="2.0", method=method, params=body)))
except (anyio.ClosedResourceError, anyio.BrokenResourceError):
logger.debug("dropped %s: response stream closed", method)
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
# TODO(D-005a): no progressToken plumbing yet; ships with the SSE response mode.
return None
if self.progress_token is None:
return
params: dict[str, Any] = {"progressToken": self.progress_token, "progress": progress}
if total is not None:
params["total"] = total
if message is not None:
params["message"] = message
await self.notify("notifications/progress", params)
def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
@ -126,6 +150,28 @@ async def _to_jsonrpc_response(
return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)
_SSE_PING_INTERVAL: float = 15.0
"""Seconds between SSE comment-line keepalives once `text/event-stream` has committed."""
_SSE_HEADERS: Final[list[tuple[bytes, bytes]]] = [
(b"content-type", b"text/event-stream"),
(b"cache-control", b"no-cache, no-transform"),
(b"connection", b"keep-alive"),
(b"x-accel-buffering", b"no"),
]
def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> bytes:
"""Serialise a JSON-RPC message as one SSE `event: message` frame.
SSE mode begins after the handler has emitted, so a `JSONRPCError` here
always carries the request's id; the `id: null` case lives in `_write`.
"""
body = msg.model_dump(mode="json", by_alias=True, exclude_none=True)
data = json.dumps(body, separators=(",", ":"))
return f"event: message\r\ndata: {data}\r\n\r\n".encode()
async def _write(
msg: JSONRPCResponse | JSONRPCError,
scope: Scope,
@ -149,6 +195,7 @@ async def _write(
async def handle_modern_request(
app: Server[Any],
security_settings: TransportSecuritySettings | None,
json_response: bool,
lifespan_state: Any,
scope: Scope,
receive: Receive,
@ -169,14 +216,17 @@ async def handle_modern_request(
await err(scope, receive, send)
return
# TODO(D-005a): validate Accept once the JSON-vs-SSE response mode is settled.
if request.method != "POST":
# HTTP-layer rejection (Allow accompanies 405 per RFC 9110) — happens
# before JSON-RPC parsing, so it doesn't go through `_write`.
await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send)
return
has_json, has_sse = check_accept_headers(request)
if not has_json or (not json_response and not has_sse):
await Response(status_code=406)(scope, receive, send)
return
body = await request.body()
try:
decoded = json.loads(body)
@ -219,8 +269,65 @@ async def handle_modern_request(
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
request_id=req.id,
message_metadata=ServerMessageMetadata(request_context=request),
progress_token=progress_token_from_params(req.params),
)
msg = await _to_jsonrpc_response(
req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state)
)
await _write(msg, scope, receive, send)
if json_response:
msg = await _to_jsonrpc_response(
req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state)
)
await _write(msg, scope, receive, send)
return
send_ch, recv_ch = anyio.create_memory_object_stream[bytes](0)
dctx.sink = send_ch
result: list[JSONRPCResponse | JSONRPCError] = []
async def run_handler() -> None:
async with send_ch:
result.append(
await _to_jsonrpc_response(
req.id,
serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state),
)
)
async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None:
while (await receive()).get("type") != "http.disconnect":
pass # pragma: no cover
cancel_scope.cancel()
async with recv_ch, anyio.create_task_group() as tg:
tg.start_soon(run_handler)
tg.start_soon(watch_disconnect, tg.cancel_scope)
event: bytes | None = None
done = False
with anyio.move_on_after(_SSE_PING_INTERVAL):
try:
event = await recv_ch.receive()
except anyio.EndOfStream:
done = True
if done:
# Handler completed within the deferral window without emitting:
# `application/json` with the table-mapped status. Kernel-dispatch
# errors (METHOD_NOT_FOUND, missing-capability, INVALID_PARAMS)
# resolve here in practice.
await _write(result[0], scope, receive, send)
else:
# First notification arrived, or the deferral window elapsed: commit
# `text/event-stream` and start pinging so a proxy idle-read timeout
# cannot close the stream (which on this path cancels the handler).
await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS})
while not done:
await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True})
event = None
with anyio.move_on_after(_SSE_PING_INTERVAL):
try:
event = await recv_ch.receive()
except anyio.EndOfStream:
done = True
await send({"type": "http.response.body", "body": _sse_event(result[0]), "more_body": False})
tg.cancel_scope.cancel()

View file

@ -76,6 +76,24 @@ EventId = str
SSEEvent = dict[str, Any]
def check_accept_headers(request: Request) -> tuple[bool, bool]:
"""Return (has_json, has_sse) for the request's Accept header, with RFC 7231 wildcard handling.
Supports wildcard media types per RFC 7231, section 5.3.2:
- */* matches any media type
- application/* matches any application/ subtype
- text/* matches any text/ subtype
"""
accept_header = request.headers.get("accept", "")
accept_types = [media_type.strip().split(";")[0].strip().lower() for media_type in accept_header.split(",")]
has_wildcard = "*/*" in accept_types
has_json = has_wildcard or any(t in (CONTENT_TYPE_JSON, "application/*") for t in accept_types)
has_sse = has_wildcard or any(t in (CONTENT_TYPE_SSE, "text/*") for t in accept_types)
return has_json, has_sse
@dataclass
class EventMessage:
"""A JSONRPCMessage with an optional event ID for stream resumability."""
@ -415,23 +433,6 @@ class StreamableHTTPServerTransport:
else:
await self._handle_unsupported_request(request, send)
def _check_accept_headers(self, request: Request) -> tuple[bool, bool]:
"""Check if the request accepts the required media types.
Supports wildcard media types per RFC 7231, section 5.3.2:
- */* matches any media type
- application/* matches any application/ subtype
- text/* matches any text/ subtype
"""
accept_header = request.headers.get("accept", "")
accept_types = [media_type.strip().split(";")[0].strip().lower() for media_type in accept_header.split(",")]
has_wildcard = "*/*" in accept_types
has_json = has_wildcard or any(t in (CONTENT_TYPE_JSON, "application/*") for t in accept_types)
has_sse = has_wildcard or any(t in (CONTENT_TYPE_SSE, "text/*") for t in accept_types)
return has_json, has_sse
def _check_content_type(self, request: Request) -> bool:
"""Check if the request has the correct Content-Type."""
content_type = request.headers.get("content-type", "")
@ -441,7 +442,7 @@ class StreamableHTTPServerTransport:
async def _validate_accept_header(self, request: Request, scope: Scope, send: Send) -> bool:
"""Validate Accept header based on response mode. Returns True if valid."""
has_json, has_sse = self._check_accept_headers(request)
has_json, has_sse = check_accept_headers(request)
if self.is_json_response_enabled:
# For JSON-only responses, only require application/json
if not has_json:
@ -661,7 +662,7 @@ class StreamableHTTPServerTransport:
raise ValueError("No read stream writer available. Ensure connect() is called first.")
# Validate Accept header - must include text/event-stream
_, has_sse = self._check_accept_headers(request)
_, has_sse = check_accept_headers(request)
if not has_sse:
response = self._create_error_response(

View file

@ -170,7 +170,9 @@ class StreamableHTTPSessionManager:
header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii")
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == header), None)
if pv is not None and pv not in HANDSHAKE_PROTOCOL_VERSIONS:
await handle_modern_request(self.app, self.security_settings, self._lifespan_state, scope, receive, send)
await handle_modern_request(
self.app, self.security_settings, self.json_response, self._lifespan_state, scope, receive, send
)
return
# Dispatch to the appropriate handler

View file

@ -49,7 +49,7 @@ from mcp.shared.message import (
)
from mcp.shared.transport_context import TransportContext
__all__ = ["JSONRPCDispatcher", "handler_exception_to_error_data"]
__all__ = ["JSONRPCDispatcher", "handler_exception_to_error_data", "progress_token_from_params"]
logger = logging.getLogger(__name__)
@ -84,6 +84,15 @@ def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None:
return None
def progress_token_from_params(params: Mapping[str, Any] | None) -> ProgressToken | None:
"""Read `params._meta.progressToken`; reject bool (bool subclasses int, so True would alias 1)."""
match params:
case {"_meta": {"progressToken": str() | int() as token}} if not isinstance(token, bool):
return token
case _:
return None
def _coerce_id(request_id: RequestId) -> RequestId:
"""Coerce a stringified int request ID back to int so a peer-echoed ID still correlates (matches the TS SDK)."""
if isinstance(request_id, str):
@ -515,13 +524,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
on_request: OnRequest,
sender_ctx: contextvars.Context | None,
) -> None:
progress_token: ProgressToken | None
match req.params:
# bool subclasses int: without the guard True would alias request id 1.
case {"_meta": {"progressToken": str() | int() as progress_token}} if not isinstance(progress_token, bool):
pass
case _:
progress_token = None
progress_token = progress_token_from_params(req.params)
try:
transport_ctx = self._transport_builder(metadata)
except Exception: