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
11 changed files with 517 additions and 112 deletions

View file

@ -28,10 +28,6 @@ client:
# neither run nor evaluated on this leg. # neither run nor evaluated on this leg.
server: server:
# The stateless 2026 path now reaches handlers for plain request/response
# scenarios; tools-call-with-progress still fails because the stateless
# server has no channel for server→client progress notifications.
- tools-call-with-progress
# SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline # SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline
# cannot return InputRequiredResult from MCPServer yet (tools/call can). # cannot return InputRequiredResult from MCPServer yet (tools/call can).
- input-required-result-non-tool-request - input-required-result-non-tool-request

View file

@ -31,8 +31,6 @@ era = "dual-in-body"
multi_connection = true multi_connection = true
[story.streaming] [story.streaming]
# progress + log notifications dropped on the modern streamable-HTTP path pending SSE wiring
xfail = ["http-asgi:modern"]
[story.mrtr] [story.mrtr]
era = "modern" era = "modern"

View file

@ -17,16 +17,12 @@ uv run python -m stories.streaming.client
uv run python -m stories.streaming.client --server server_lowlevel uv run python -m stories.streaming.client --server server_lowlevel
# HTTP — the client self-hosts the server on a free port, runs, then tears it # HTTP — the client self-hosts the server on a free port, runs, then tears it
# down (--legacy: see the note below) # down
uv run python -m stories.streaming.client --http --legacy uv run python -m stories.streaming.client --http
# same, against the lowlevel-API server variant # same, against the lowlevel-API server variant
uv run python -m stories.streaming.client --http --legacy --server server_lowlevel uv run python -m stories.streaming.client --http --server server_lowlevel
``` ```
The modern HTTP leg (drop `--legacy`) is `xfail` until the SSE wiring lands —
mid-call progress and log notifications are currently dropped there (see
Caveats).
## What to look at ## What to look at
- `client.py` `main` — opens with `async with Client(target, mode=mode, - `client.py` `main` — opens with `async with Client(target, mode=mode,
@ -60,9 +56,6 @@ Caveats).
OpenTelemetry instead of `notifications/message`. It is shown here because OpenTelemetry instead of `notifications/message`. It is shown here because
servers still need to support 2025-era clients during that window. Progress servers still need to support 2025-era clients during that window. Progress
and cancellation are **not** deprecated. TODO(maxisbey): revisit before beta. and cancellation are **not** deprecated. TODO(maxisbey): revisit before beta.
- On the modern (2026-07-28) streamable-HTTP path, mid-call progress and log
notifications are currently dropped pending the SSE wiring; the
`http-asgi:modern` leg of this story is `xfail` until that lands.
- When a request is cancelled the server currently replies with - When a request is cancelled the server currently replies with
`ErrorData(code=0, message="Request cancelled")`; the spec says it should not `ErrorData(code=0, message="Request cancelled")`; the spec says it should not
reply at all. The client never observes it (its awaiting task is already reply at all. The client never observes it (its awaiting task is already

View file

@ -156,7 +156,10 @@ class StreamableHTTPTransport:
# Otherwise, return False to continue listening # Otherwise, return False to continue listening
return isinstance(message, JSONRPCResponse | JSONRPCError) 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") logger.exception("Error parsing SSE message")
if original_request_id is not None: if original_request_id is not None:
error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse SSE message: {exc}") error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse SSE message: {exc}")
@ -372,7 +375,7 @@ class StreamableHTTPTransport:
await response.aclose() await response.aclose()
return # Normal completion, no reconnect needed return # Normal completion, no reconnect needed
except Exception: 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 # Stream ended without response - reconnect if we received an event with ID
if last_event_id is not None: # pragma: no branch 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. path for earlier protocol revisions.
A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no 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 `Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON
module handles such a request directly in the ASGI task - no memory streams, mode handles the request directly in the ASGI task. SSE mode runs the handler
no per-request task group, no `JSONRPCDispatcher`. 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 from __future__ import annotations
@ -16,9 +22,10 @@ import json
import logging import logging
from collections.abc import Awaitable, Mapping from collections.abc import Awaitable, Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, TypeVar from typing import TYPE_CHECKING, Any, Final, TypeVar
import anyio import anyio
from anyio.streams.memory import MemoryObjectSendStream
from mcp_types import ( from mcp_types import (
INTERNAL_ERROR, INTERNAL_ERROR,
INVALID_REQUEST, INVALID_REQUEST,
@ -27,8 +34,10 @@ from mcp_types import (
ErrorData, ErrorData,
Implementation, Implementation,
JSONRPCError, JSONRPCError,
JSONRPCNotification,
JSONRPCRequest, JSONRPCRequest,
JSONRPCResponse, JSONRPCResponse,
ProgressToken,
RequestId, RequestId,
) )
from pydantic import BaseModel, ValidationError 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.connection import Connection
from mcp.server.runner import serve_one 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.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
from mcp.shared.dispatcher import CallOptions from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError from mcp.shared.exceptions import NoBackChannelError
@ -46,7 +56,7 @@ from mcp.shared.inbound import (
InboundLadderRejection, InboundLadderRejection,
classify_inbound_request, 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.message import MessageMetadata, ServerMessageMetadata
from mcp.shared.transport_context import TransportContext from mcp.shared.transport_context import TransportContext
@ -66,12 +76,15 @@ class _SingleExchangeDispatchContext:
Structurally satisfies `mcp.shared.dispatcher.DispatchContext`. The Structurally satisfies `mcp.shared.dispatcher.DispatchContext`. The
back-channel is closed by construction: a 2026-07-28 server cannot send 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 transport: TransportContext
request_id: RequestId request_id: RequestId
message_metadata: MessageMetadata message_metadata: MessageMetadata
progress_token: ProgressToken | None = None
sink: MemoryObjectSendStream[bytes] | None = None
cancel_requested: anyio.Event = field(default_factory=anyio.Event) cancel_requested: anyio.Event = field(default_factory=anyio.Event)
can_send_request: bool = field(default=False, init=False) can_send_request: bool = field(default=False, init=False)
@ -84,12 +97,23 @@ class _SingleExchangeDispatchContext:
raise NoBackChannelError(method) raise NoBackChannelError(method)
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: 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. if self.sink is None:
return 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: 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. if self.progress_token is None:
return 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: 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) 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( async def _write(
msg: JSONRPCResponse | JSONRPCError, msg: JSONRPCResponse | JSONRPCError,
scope: Scope, scope: Scope,
@ -149,6 +195,7 @@ async def _write(
async def handle_modern_request( async def handle_modern_request(
app: Server[Any], app: Server[Any],
security_settings: TransportSecuritySettings | None, security_settings: TransportSecuritySettings | None,
json_response: bool,
lifespan_state: Any, lifespan_state: Any,
scope: Scope, scope: Scope,
receive: Receive, receive: Receive,
@ -169,14 +216,17 @@ async def handle_modern_request(
await err(scope, receive, send) await err(scope, receive, send)
return return
# TODO(D-005a): validate Accept once the JSON-vs-SSE response mode is settled.
if request.method != "POST": if request.method != "POST":
# HTTP-layer rejection (Allow accompanies 405 per RFC 9110) — happens # HTTP-layer rejection (Allow accompanies 405 per RFC 9110) — happens
# before JSON-RPC parsing, so it doesn't go through `_write`. # before JSON-RPC parsing, so it doesn't go through `_write`.
await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send) await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send)
return 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() body = await request.body()
try: try:
decoded = json.loads(body) 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), transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
request_id=req.id, request_id=req.id,
message_metadata=ServerMessageMetadata(request_context=request), 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) if json_response:
) msg = await _to_jsonrpc_response(
await _write(msg, scope, receive, send) 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] 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 @dataclass
class EventMessage: class EventMessage:
"""A JSONRPCMessage with an optional event ID for stream resumability.""" """A JSONRPCMessage with an optional event ID for stream resumability."""
@ -415,23 +433,6 @@ class StreamableHTTPServerTransport:
else: else:
await self._handle_unsupported_request(request, send) 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: def _check_content_type(self, request: Request) -> bool:
"""Check if the request has the correct Content-Type.""" """Check if the request has the correct Content-Type."""
content_type = request.headers.get("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: async def _validate_accept_header(self, request: Request, scope: Scope, send: Send) -> bool:
"""Validate Accept header based on response mode. Returns True if valid.""" """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: if self.is_json_response_enabled:
# For JSON-only responses, only require application/json # For JSON-only responses, only require application/json
if not has_json: if not has_json:
@ -661,7 +662,7 @@ class StreamableHTTPServerTransport:
raise ValueError("No read stream writer available. Ensure connect() is called first.") raise ValueError("No read stream writer available. Ensure connect() is called first.")
# Validate Accept header - must include text/event-stream # 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: if not has_sse:
response = self._create_error_response( response = self._create_error_response(

View file

@ -170,7 +170,9 @@ class StreamableHTTPSessionManager:
header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii") header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii")
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == header), None) 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: 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 return
# Dispatch to the appropriate handler # Dispatch to the appropriate handler

View file

@ -49,7 +49,7 @@ from mcp.shared.message import (
) )
from mcp.shared.transport_context import TransportContext 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__) logger = logging.getLogger(__name__)
@ -84,6 +84,15 @@ def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None:
return 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: 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).""" """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): if isinstance(request_id, str):
@ -515,13 +524,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
on_request: OnRequest, on_request: OnRequest,
sender_ctx: contextvars.Context | None, sender_ctx: contextvars.Context | None,
) -> None: ) -> None:
progress_token: ProgressToken | None progress_token = progress_token_from_params(req.params)
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
try: try:
transport_ctx = self._transport_builder(metadata) transport_ctx = self._transport_builder(metadata)
except Exception: except Exception:

View file

@ -99,7 +99,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
for leg, cfg in _legs(): for leg, cfg in _legs():
marks: list[pytest.MarkDecorator] = [] marks: list[pytest.MarkDecorator] = []
if f"{leg.transport}:{leg.era}" in cfg["xfail"]: if f"{leg.transport}:{leg.era}" in cfg["xfail"]:
marks.append(pytest.mark.xfail(strict=True, reason="manifest xfail")) marks.append(pytest.mark.xfail(strict=True, reason="manifest xfail")) # pragma: lax no cover
params.append(pytest.param(leg, marks=marks, id=leg.id)) params.append(pytest.param(leg, marks=marks, id=leg.id))
metafunc.parametrize("leg", params) metafunc.parametrize("leg", params)

View file

@ -93,12 +93,6 @@ _TASKS_DEFERRAL = (
"unimplemented." "unimplemented."
) )
_MODERN_NOTIFY_DROP = (
"The modern single-exchange dispatch context no-ops notify() on the streamable-http driver; "
"handler-emitted logging/progress notifications never reach the per-request SSE response. "
"Passes once SSE response mode lands."
)
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class Divergence: class Divergence:
@ -656,9 +650,6 @@ REQUIREMENTS: dict[str, Requirement] = {
"Progress notifications emitted by a handler during a request are delivered to the caller's " "Progress notifications emitted by a handler during a request are delivered to the caller's "
"progress callback, in order, with their progress, total, and message." "progress callback, in order, with their progress, total, and message."
), ),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
), ),
"protocol:progress:token-injected": Requirement( "protocol:progress:token-injected": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -676,9 +667,6 @@ REQUIREMENTS: dict[str, Requirement] = {
"interleaved emission. Token distinctness is the JSON-RPC mechanism for that; the in-process " "interleaved emission. Token distinctness is the JSON-RPC mechanism for that; the in-process "
"direct dispatcher carries the callback per-request without a wire-level token." "direct dispatcher carries the callback per-request without a wire-level token."
), ),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
), ),
"protocol:progress:monotonic": Requirement( "protocol:progress:monotonic": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -691,9 +679,6 @@ REQUIREMENTS: dict[str, Requirement] = {
"handler that emits non-increasing values has them forwarded to the callback unchanged." "handler that emits non-increasing values has them forwarded to the callback unchanged."
), ),
), ),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
), ),
"protocol:progress:stops-after-completion": Requirement( "protocol:progress:stops-after-completion": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements", source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements",
@ -831,9 +816,6 @@ REQUIREMENTS: dict[str, Requirement] = {
"Log notifications emitted by a tool handler during execution reach the client's logging " "Log notifications emitted by a tool handler during execution reach the client's logging "
"callback before the tool result returns." "callback before the tool result returns."
), ),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
), ),
"tools:call:progress": Requirement( "tools:call:progress": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -841,9 +823,6 @@ REQUIREMENTS: dict[str, Requirement] = {
"Progress notifications emitted by a tool handler reach the caller's progress callback before " "Progress notifications emitted by a tool handler reach the caller's progress callback before "
"the tool result returns." "the tool result returns."
), ),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
), ),
"tools:call:sampling-roundtrip": Requirement( "tools:call:sampling-roundtrip": Requirement(
source=f"{SPEC_BASE_URL}/client/sampling#creating-messages", source=f"{SPEC_BASE_URL}/client/sampling#creating-messages",
@ -1064,18 +1043,12 @@ REQUIREMENTS: dict[str, Requirement] = {
"The Context logging helpers (debug/info/warning/error) send log message notifications at the " "The Context logging helpers (debug/info/warning/error) send log message notifications at the "
"corresponding severity." "corresponding severity."
), ),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
), ),
"mcpserver:context:progress": Requirement( "mcpserver:context:progress": Requirement(
source="sdk", source="sdk",
behavior=( behavior=(
"Context.report_progress sends a progress notification against the requesting client's progress token." "Context.report_progress sends a progress notification against the requesting client's progress token."
), ),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
), ),
"mcpserver:context:elicit": Requirement( "mcpserver:context:elicit": Requirement(
source="sdk", source="sdk",
@ -1433,9 +1406,6 @@ REQUIREMENTS: dict[str, Requirement] = {
"logging:message:all-levels": Requirement( "logging:message:all-levels": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels", source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels",
behavior="All eight RFC 5424 severity levels are deliverable as log message notifications.", behavior="All eight RFC 5424 severity levels are deliverable as log message notifications.",
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
), ),
"logging:message:fields": Requirement( "logging:message:fields": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications", source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications",
@ -1443,9 +1413,6 @@ REQUIREMENTS: dict[str, Requirement] = {
"A log message sent by a server handler is delivered to the client's logging callback with its " "A log message sent by a server handler is delivered to the client's logging callback with its "
"severity level, logger name, and data." "severity level, logger name, and data."
), ),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
), ),
"logging:message:filtered": Requirement( "logging:message:filtered": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level", source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level",
@ -2065,17 +2032,6 @@ REQUIREMENTS: dict[str, Requirement] = {
"client cannot learn that the set changed without polling." "client cannot learn that the set changed without polling."
), ),
), ),
known_failures=(
KnownFailure(
spec_version="2026-07-28",
transport="streamable-http",
note=(
"List-mutation assertions hold; only the sentinel ctx.info() never reaches the client. "
+ _MODERN_NOTIFY_DROP
),
issue=None,
),
),
), ),
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# Pagination # Pagination

View file

@ -6,6 +6,7 @@ the closed back-channel on the dispatch context, and the request-validation ladd
``handle_modern_request``. ``handle_modern_request``.
""" """
import json
import logging import logging
from typing import Any from typing import Any
@ -26,11 +27,14 @@ from mcp_types import (
JSONRPCError, JSONRPCError,
JSONRPCResponse, JSONRPCResponse,
ListToolsResult, ListToolsResult,
LoggingMessageNotification,
LoggingMessageNotificationParams,
PaginatedRequestParams, PaginatedRequestParams,
Tool, Tool,
) )
from mcp_types.version import LATEST_MODERN_VERSION from mcp_types.version import LATEST_MODERN_VERSION
from starlette.types import Receive, Scope, Send from starlette.types import Message, Receive, Scope, Send
from trio.testing import MockClock
from mcp.server import Server, ServerRequestContext, runner from mcp.server import Server, ServerRequestContext, runner
from mcp.server._streamable_http_modern import ( from mcp.server._streamable_http_modern import (
@ -42,12 +46,14 @@ from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.transport_context import TransportContext from mcp.shared.transport_context import TransportContext
from tests.interaction.transports import StreamingASGITransport
pytestmark = pytest.mark.anyio pytestmark = pytest.mark.anyio
async def test_single_exchange_dispatch_context_has_no_back_channel() -> None: async def test_single_exchange_dispatch_context_has_no_back_channel() -> None:
"""The per-request dispatch context refuses server-initiated requests and drops notify/progress.""" """The per-request dispatch context refuses server-initiated requests; without an SSE sink,
notify/progress are no-ops."""
dctx = _SingleExchangeDispatchContext( dctx = _SingleExchangeDispatchContext(
transport=TransportContext(kind="streamable-http", can_send_request=False), transport=TransportContext(kind="streamable-http", can_send_request=False),
request_id=1, request_id=1,
@ -60,17 +66,24 @@ async def test_single_exchange_dispatch_context_has_no_back_channel() -> None:
assert await dctx.progress(0.5, total=1.0, message="half") is None assert await dctx.progress(0.5, total=1.0, message="half") is None
def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient: def _asgi_client(
server: Server[Any],
security_settings: TransportSecuritySettings | None = None,
*,
json_response: bool = True,
accept: str = "application/json, text/event-stream",
) -> httpx.AsyncClient:
async def app(scope: Scope, receive: Receive, send: Send) -> None: async def app(scope: Scope, receive: Receive, send: Send) -> None:
async with server.lifespan(server) as lifespan_state: async with server.lifespan(server) as lifespan_state:
await handle_modern_request(server, security_settings, lifespan_state, scope, receive, send) await handle_modern_request(server, security_settings, json_response, lifespan_state, scope, receive, send)
return httpx.AsyncClient( return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), transport=StreamingASGITransport(app),
base_url="http://testserver", base_url="http://testserver",
headers={ headers={
MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION,
"content-type": "application/json", "content-type": "application/json",
"accept": accept,
}, },
) )
@ -301,3 +314,336 @@ async def test_handle_modern_request_rejects_mismatched_name_header_with_400_and
) )
assert response.status_code == 400 assert response.status_code == 400
assert response.json()["error"]["code"] == HEADER_MISMATCH assert response.json()["error"]["code"] == HEADER_MISMATCH
# --- SSE response mode ---------------------------------------------------------
def _sse_payloads(body: str) -> list[dict[str, Any]]:
"""Parse an SSE body into the list of JSON `data:` payloads, in delivery order."""
return [
json.loads(line.removeprefix("data:").strip())
for line in body.replace("\r\n", "\n").splitlines()
if line.startswith("data:")
]
def _list_tools_body_with_token(token: str | int) -> dict[str, Any]:
body = _list_tools_body()
body["params"]["_meta"]["progressToken"] = token
return body
async def test_sse_mode_streams_progress_then_result() -> None:
"""SSE mode: a handler's `report_progress` calls stream as `notifications/progress` events
(carrying the request's progressToken) before the terminal JSON-RPC response event.
Spec-mandated: `notifications/progress` carries the caller's token; the per-request SSE stream
closes after the terminal response. Asserted at the wire because Content-Type and event order
are the contract.
"""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
await ctx.session.report_progress(1.0, total=3.0)
await ctx.session.report_progress(2.0, total=3.0, message="almost")
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http:
with anyio.fail_after(5):
response = await http.post(
"/mcp", json=_list_tools_body_with_token("tok-1"), headers={MCP_METHOD_HEADER: "tools/list"}
)
assert response.status_code == 200
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
events = _sse_payloads(response.text)
assert len(events) == 3
assert events[0] == {
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {"progressToken": "tok-1", "progress": 1.0, "total": 3.0},
}
assert events[1] == {
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {"progressToken": "tok-1", "progress": 2.0, "total": 3.0, "message": "almost"},
}
assert events[2]["id"] == 1
assert events[2]["result"]["tools"] == []
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_sse_mode_emits_keepalive_comment_between_events(monkeypatch: pytest.MonkeyPatch) -> None:
"""SSE mode: while the stream is idle between events the server emits an SSE comment line so a
proxy idle-read timeout does not close the stream (which would cancel the handler).
SDK-defined: spec encourages keepalive comments for long-lived streams.
Runs on trio's autojumping MockClock so the `move_on_after(_SSE_PING_INTERVAL)` deadlines and
the handler's `anyio.sleep` advance without wall-clock time."""
monkeypatch.setattr("mcp.server._streamable_http_modern._SSE_PING_INTERVAL", 1.0)
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
await ctx.session.report_progress(1.0)
await anyio.sleep(2.5)
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http:
with anyio.fail_after(5):
response = await http.post(
"/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"}
)
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
assert response.content.count(b": ping\r\n\r\n") == 2
events = _sse_payloads(response.text)
assert len(events) == 2
assert events[0]["method"] == "notifications/progress"
assert events[1]["result"]["tools"] == []
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_sse_mode_silent_handler_commits_sse_after_ping_interval(monkeypatch: pytest.MonkeyPatch) -> None:
"""SSE mode: a handler that runs silent past the deferral window commits `text/event-stream`
and starts pinging — even though it never emits a notification — so a proxy idle-read timeout
does not close the connection and cancel it. SDK-defined: the deferral window is bounded by
`_SSE_PING_INTERVAL`.
Runs on trio's autojumping MockClock; the 2.5s handler sleep takes no wall-clock time."""
monkeypatch.setattr("mcp.server._streamable_http_modern._SSE_PING_INTERVAL", 1.0)
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
await anyio.sleep(2.5)
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http:
with anyio.fail_after(5):
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
assert response.status_code == 200
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
assert response.content.count(b": ping\r\n\r\n") == 2
events = _sse_payloads(response.text)
assert len(events) == 1
assert events[0]["result"]["tools"] == []
async def test_sse_mode_streams_log_notification() -> None:
"""SSE mode: a request-scoped `notifications/message` emitted by the handler precedes the
terminal response on the same stream. SDK-defined: notifications sent on the request's outbound
channel reach the per-request SSE response."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
await ctx.session.send_notification(
LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="hello")),
related_request_id=ctx.request_id,
)
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http:
with anyio.fail_after(5):
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
events = _sse_payloads(response.text)
assert len(events) == 2
assert events[0]["method"] == "notifications/message"
assert events[0]["params"] == {"level": "info", "data": "hello"}
assert events[1]["result"]["tools"] == []
async def test_json_mode_drops_progress() -> None:
"""JSON mode: `report_progress` is a no-op (no sink); the response is a plain
`application/json` body carrying only the terminal result. SDK-defined."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
await ctx.session.report_progress(1, total=2)
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=True) as http:
response = await http.post(
"/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"}
)
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
body = response.json()
assert body["id"] == 1
assert body["result"]["tools"] == []
assert "notifications/progress" not in response.text
async def test_sse_mode_error_before_any_notify_is_json_with_mapped_status() -> None:
"""SSE mode: an error raised before the handler emits any notification is written as
`application/json` with the table-mapped HTTP status — SSE has not committed yet.
Spec-mandated: METHOD_NOT_FOUND MUST be `404 Not Found`."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
raise MCPError(code=METHOD_NOT_FOUND, message="nope")
async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http:
with anyio.fail_after(5):
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
assert response.status_code == 404
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
assert response.json() == {"jsonrpc": "2.0", "id": 1, "error": {"code": METHOD_NOT_FOUND, "message": "nope"}}
async def test_sse_mode_error_after_notify_is_sse_event() -> None:
"""SSE mode: an error raised after the handler has emitted is delivered as the terminal SSE
event (HTTP 200) — `text/event-stream` headers were committed on the first notification."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
await ctx.session.report_progress(1.0)
raise MCPError(code=INTERNAL_ERROR, message="boom")
async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http:
with anyio.fail_after(5):
response = await http.post(
"/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"}
)
assert response.status_code == 200
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
events = _sse_payloads(response.text)
assert len(events) == 2
assert events[0]["method"] == "notifications/progress"
assert events[1] == {"jsonrpc": "2.0", "id": 1, "error": {"code": INTERNAL_ERROR, "message": "boom"}}
async def test_sse_mode_no_notify_response_is_json() -> None:
"""SSE mode: a handler that emits nothing (here `report_progress` is a no-op because no
`progressToken` was supplied) gets a plain `application/json` response. SDK-defined: SSE only
commits once there is something to stream."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
await ctx.session.report_progress(1, total=2)
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http:
with anyio.fail_after(5):
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
assert response.status_code == 200
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
assert response.json()["result"]["tools"] == []
async def test_accept_missing_sse_406_in_sse_mode() -> None:
"""SDK-defined: in SSE mode the client must accept both `application/json` and
`text/event-stream`; an Accept header naming only JSON is rejected at HTTP 406 before any
JSON-RPC parsing."""
async with _asgi_client(Server("test"), json_response=False, accept="application/json") as http:
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
assert response.status_code == 406
assert response.content == b""
async def test_accept_missing_sse_ok_in_json_mode() -> None:
"""SDK-defined: in JSON mode only `application/json` need be acceptable; an Accept header that
omits `text/event-stream` still routes (200 + result)."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
async with _asgi_client(
Server("test", on_list_tools=list_tools), json_response=True, accept="application/json"
) as http:
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
assert response.status_code == 200
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
@pytest.mark.parametrize("json_response", [True, False])
async def test_accept_wildcard_satisfies_both_response_modes(json_response: bool) -> None:
"""SDK-defined: `Accept: */*` satisfies both representations (RFC 7231 wildcard) in either
response mode."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
async with _asgi_client(
Server("test", on_list_tools=list_tools), json_response=json_response, accept="*/*"
) as http:
with anyio.fail_after(5):
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
assert response.status_code == 200
async def test_late_notify_after_terminal_dropped() -> None:
"""SDK-defined: a `notify()` after the SSE sink has closed is silently dropped — the closed
stream must not propagate as an exception out of the dispatch context."""
send_ch, recv_ch = anyio.create_memory_object_stream[bytes](0)
dctx = _SingleExchangeDispatchContext(
transport=TransportContext(kind="streamable-http", can_send_request=False),
request_id=1,
message_metadata=None,
sink=send_ch,
)
await recv_ch.aclose()
# Neither raises despite the receiver being gone (BrokenResourceError caught and dropped).
assert await dctx.notify("notifications/message", {"level": "info", "data": "late"}) is None
dctx.progress_token = "tok"
assert await dctx.progress(1.0) is None
await send_ch.aclose()
async def test_disconnect_cancels_handler_and_runs_exit_stack() -> None:
"""SSE mode: when the client disconnects mid-stream the handler task is cancelled and
`connection.exit_stack` still unwinds. SDK-defined: `serve_one`'s shielded cleanup runs in the
cancellation path so handler-registered teardown is not skipped on disconnect."""
handler_started = anyio.Event()
cleanup_ran = anyio.Event()
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
ctx.session._connection.exit_stack.callback(cleanup_ran.set)
handler_started.set()
await anyio.Event().wait()
raise AssertionError("unreachable") # pragma: no cover
server: Server[Any] = Server("test", on_list_tools=list_tools)
body = json.dumps(_list_tools_body()).encode()
scope: Scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"server": ("testserver", 80),
"client": ("127.0.0.1", 1234),
"path": "/mcp",
"raw_path": b"/mcp",
"query_string": b"",
"root_path": "",
"headers": [
(b"host", b"testserver"),
(b"content-type", b"application/json"),
(b"accept", b"application/json, text/event-stream"),
(MCP_PROTOCOL_VERSION_HEADER.encode(), LATEST_MODERN_VERSION.encode()),
(MCP_METHOD_HEADER.encode(), b"tools/list"),
],
}
request_delivered = anyio.Event()
async def receive() -> Message:
# First call delivers the request body; once the handler is parked, deliver disconnect.
if not request_delivered.is_set():
request_delivered.set()
return {"type": "http.request", "body": body, "more_body": False}
await handler_started.wait()
return {"type": "http.disconnect"}
async def send(message: Message) -> None: # pragma: no cover
pass
with anyio.fail_after(5):
async with server.lifespan(server) as lifespan_state:
await handle_modern_request(server, None, False, lifespan_state, scope, receive, send)
await cleanup_ran.wait()
assert handler_started.is_set()