Skip to content

Commit 5820a22

Browse files
committed
Defer SSE start until first notification; restore 404/400 status mapping
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).
1 parent ea346df commit 5820a22

4 files changed

Lines changed: 94 additions & 45 deletions

File tree

src/mcp/server/_streamable_http_modern.py

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
77
A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no
88
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON
9-
mode handles the request directly in the ASGI task; SSE mode runs the handler
10-
as a sibling task inside an `EventSourceResponse` so request-scoped
11-
notifications stream before the terminal response.
9+
mode handles the request directly in the ASGI task. SSE mode runs the handler
10+
as a sibling task and defers committing to `text/event-stream` until the
11+
handler actually emits a notification: a handler that completes (or raises)
12+
without emitting still gets a JSON response with the table-mapped HTTP
13+
status, so the spec's `404`/`400` MUSTs hold for kernel-dispatch errors.
1214
"""
1315

1416
from __future__ import annotations
@@ -17,7 +19,7 @@
1719
import logging
1820
from collections.abc import Awaitable, Mapping
1921
from dataclasses import dataclass, field
20-
from typing import TYPE_CHECKING, Any, TypeVar
22+
from typing import TYPE_CHECKING, Any, Final, TypeVar
2123

2224
import anyio
2325
from anyio.streams.memory import MemoryObjectSendStream
@@ -36,7 +38,6 @@
3638
RequestId,
3739
)
3840
from pydantic import BaseModel, ValidationError
39-
from sse_starlette import EventSourceResponse
4041
from starlette.requests import Request
4142
from starlette.responses import Response
4243
from starlette.types import Receive, Scope, Send
@@ -80,7 +81,7 @@ class _SingleExchangeDispatchContext:
8081
request_id: RequestId
8182
message_metadata: MessageMetadata
8283
progress_token: ProgressToken | None = None
83-
sink: MemoryObjectSendStream[dict[str, str]] | None = None
84+
sink: MemoryObjectSendStream[bytes] | None = None
8485
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
8586
can_send_request: bool = field(default=False, init=False)
8687

@@ -146,14 +147,23 @@ async def _to_jsonrpc_response(
146147
return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)
147148

148149

149-
def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> dict[str, str]:
150-
"""Serialise a JSON-RPC message as an sse-starlette event dict.
150+
_SSE_HEADERS: Final[list[tuple[bytes, bytes]]] = [
151+
(b"content-type", b"text/event-stream"),
152+
(b"cache-control", b"no-store"),
153+
(b"connection", b"keep-alive"),
154+
(b"x-accel-buffering", b"no"),
155+
]
151156

152-
SSE mode begins after the request body has parsed, so a `JSONRPCError` here
157+
158+
def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> bytes:
159+
"""Serialise a JSON-RPC message as one SSE `event: message` frame.
160+
161+
SSE mode begins after the handler has emitted, so a `JSONRPCError` here
153162
always carries the request's id; the `id: null` case lives in `_write`.
154163
"""
155164
body = msg.model_dump(mode="json", by_alias=True, exclude_none=True)
156-
return {"event": "message", "data": json.dumps(body, separators=(",", ":"))}
165+
data = json.dumps(body, separators=(",", ":"))
166+
return f"event: message\r\ndata: {data}\r\n\r\n".encode()
157167

158168

159169
async def _write(
@@ -263,18 +273,40 @@ async def handle_modern_request(
263273
await _write(msg, scope, receive, send)
264274
return
265275

266-
send_ch, recv_ch = anyio.create_memory_object_stream[dict[str, str]](0)
276+
send_ch, recv_ch = anyio.create_memory_object_stream[bytes](0)
267277
dctx.sink = send_ch
278+
result: list[JSONRPCResponse | JSONRPCError] = []
268279

269280
async def run_handler() -> None:
270281
async with send_ch:
271-
msg = await _to_jsonrpc_response(
272-
req.id,
273-
serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state),
282+
result.append(
283+
await _to_jsonrpc_response(
284+
req.id,
285+
serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state),
286+
)
274287
)
275-
await send_ch.send(_sse_event(msg))
276288

277-
try:
278-
await EventSourceResponse(content=recv_ch, data_sender_callable=run_handler)(scope, receive, send)
279-
finally:
280-
await recv_ch.aclose()
289+
async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None:
290+
while (await receive()).get("type") != "http.disconnect":
291+
pass # pragma: no cover
292+
cancel_scope.cancel()
293+
294+
async with recv_ch, anyio.create_task_group() as tg:
295+
tg.start_soon(run_handler)
296+
tg.start_soon(watch_disconnect, tg.cancel_scope)
297+
298+
try:
299+
first = await recv_ch.receive()
300+
except anyio.EndOfStream:
301+
first = None
302+
303+
if first is None:
304+
await _write(result[0], scope, receive, send)
305+
else:
306+
await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS})
307+
await send({"type": "http.response.body", "body": first, "more_body": True})
308+
async for event in recv_ch:
309+
await send({"type": "http.response.body", "body": event, "more_body": True})
310+
await send({"type": "http.response.body", "body": _sse_event(result[0]), "more_body": False})
311+
312+
tg.cancel_scope.cancel()

src/mcp/server/streamable_http.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -433,9 +433,6 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No
433433
else:
434434
await self._handle_unsupported_request(request, send)
435435

436-
def _check_accept_headers(self, request: Request) -> tuple[bool, bool]:
437-
return check_accept_headers(request)
438-
439436
def _check_content_type(self, request: Request) -> bool:
440437
"""Check if the request has the correct Content-Type."""
441438
content_type = request.headers.get("content-type", "")
@@ -445,7 +442,7 @@ def _check_content_type(self, request: Request) -> bool:
445442

446443
async def _validate_accept_header(self, request: Request, scope: Scope, send: Send) -> bool:
447444
"""Validate Accept header based on response mode. Returns True if valid."""
448-
has_json, has_sse = self._check_accept_headers(request)
445+
has_json, has_sse = check_accept_headers(request)
449446
if self.is_json_response_enabled:
450447
# For JSON-only responses, only require application/json
451448
if not has_json:
@@ -665,7 +662,7 @@ async def _handle_get_request(self, request: Request, send: Send) -> None:
665662
raise ValueError("No read stream writer available. Ensure connect() is called first.")
666663

667664
# Validate Accept header - must include text/event-stream
668-
_, has_sse = self._check_accept_headers(request)
665+
_, has_sse = check_accept_headers(request)
669666

670667
if not has_sse:
671668
response = self._create_error_response(

tests/interaction/transports/test_hosting_http_modern.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ async def test_modern_tools_call_returns_result_type_complete_without_initialize
106106
"method": "tools/call",
107107
"params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()},
108108
}
109-
async with mounted_app(_server(), json_response=True) as (http, _):
109+
async with mounted_app(_server()) as (http, _):
110110
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="add"))
111111

112112
assert response.status_code == 200
@@ -151,7 +151,7 @@ async def test_modern_initialize_is_method_not_found() -> None:
151151
negative.
152152
"""
153153
body = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"_meta": _meta_envelope()}}
154-
async with mounted_app(_server(), json_response=True) as (http, _):
154+
async with mounted_app(_server()) as (http, _):
155155
response = await http.post("/mcp", json=body, headers=_modern_headers(method="initialize"))
156156

157157
assert response.status_code == 404
@@ -199,7 +199,7 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
199199
"method": "tools/call",
200200
"params": {"name": "boom", "arguments": {}, "_meta": _meta_envelope()},
201201
}
202-
async with mounted_app(Server("modern", on_call_tool=call_tool), json_response=True) as (http, _):
202+
async with mounted_app(Server("modern", on_call_tool=call_tool)) as (http, _):
203203
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="boom"))
204204

205205
assert response.status_code == 200
@@ -218,7 +218,7 @@ async def test_modern_server_discover_returns_capabilities_and_supported_version
218218
the raw result body.
219219
"""
220220
body = {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": _meta_envelope()}}
221-
async with mounted_app(_server(), json_response=True) as (http, _):
221+
async with mounted_app(_server()) as (http, _):
222222
response = await http.post("/mcp", json=body, headers=_modern_headers(method="server/discover"))
223223

224224
assert response.status_code == 200
@@ -238,7 +238,7 @@ async def test_modern_removed_method_is_method_not_found_at_http_404() -> None:
238238
wire because the HTTP status is the assertion.
239239
"""
240240
body = {"jsonrpc": "2.0", "id": 1, "method": "ping", "params": {"_meta": _meta_envelope()}}
241-
async with mounted_app(_server(), json_response=True) as (http, _):
241+
async with mounted_app(_server()) as (http, _):
242242
response = await http.post("/mcp", json=body, headers=_modern_headers(method="ping"))
243243

244244
assert response.status_code == 404
@@ -285,7 +285,7 @@ async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyRe
285285
server = _server()
286286
server.add_request_handler("test/cap-check", RequestParams, cap_check)
287287
body = {"jsonrpc": "2.0", "id": 1, "method": "test/cap-check", "params": {"_meta": _meta_envelope()}}
288-
async with mounted_app(server, json_response=True) as (http, _):
288+
async with mounted_app(server) as (http, _):
289289
response = await http.post("/mcp", json=body, headers=_modern_headers(method="test/cap-check"))
290290

291291
assert response.status_code == 400

tests/server/test_streamable_http_modern.py

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -415,10 +415,10 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
415415
assert "notifications/progress" not in response.text
416416

417417

418-
async def test_sse_mode_handler_error_is_event() -> None:
419-
"""SSE mode: a handler-raised `MCPError` is delivered as the terminal SSE event (HTTP 200).
420-
SDK-defined: SSE responses commit headers before the handler runs, so the error rides the
421-
event stream rather than the HTTP status line."""
418+
async def test_sse_mode_error_before_any_notify_is_json_with_mapped_status() -> None:
419+
"""SSE mode: an error raised before the handler emits any notification is written as
420+
`application/json` with the table-mapped HTTP status — SSE has not committed yet.
421+
Spec-mandated: METHOD_NOT_FOUND MUST be `404 Not Found`."""
422422

423423
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
424424
raise MCPError(code=METHOD_NOT_FOUND, message="nope")
@@ -427,17 +427,37 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
427427
with anyio.fail_after(5):
428428
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
429429

430+
assert response.status_code == 404
431+
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
432+
assert response.json() == {"jsonrpc": "2.0", "id": 1, "error": {"code": METHOD_NOT_FOUND, "message": "nope"}}
433+
434+
435+
async def test_sse_mode_error_after_notify_is_sse_event() -> None:
436+
"""SSE mode: an error raised after the handler has emitted is delivered as the terminal SSE
437+
event (HTTP 200) — `text/event-stream` headers were committed on the first notification."""
438+
439+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
440+
await ctx.session.report_progress(1.0)
441+
raise MCPError(code=INTERNAL_ERROR, message="boom")
442+
443+
async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http:
444+
with anyio.fail_after(5):
445+
response = await http.post(
446+
"/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"}
447+
)
448+
430449
assert response.status_code == 200
431450
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
432451
events = _sse_payloads(response.text)
433-
assert len(events) == 1
434-
assert events[0] == {"jsonrpc": "2.0", "id": 1, "error": {"code": METHOD_NOT_FOUND, "message": "nope"}}
452+
assert len(events) == 2
453+
assert events[0]["method"] == "notifications/progress"
454+
assert events[1] == {"jsonrpc": "2.0", "id": 1, "error": {"code": INTERNAL_ERROR, "message": "boom"}}
435455

436456

437-
async def test_sse_mode_no_token_progress_noop() -> None:
438-
"""SSE mode: with no `progressToken` in `_meta`, `report_progress` is a no-op and the stream
439-
carries only the terminal event. Spec-mandated: progress notifications are sent only against a
440-
caller-supplied token."""
457+
async def test_sse_mode_no_notify_response_is_json() -> None:
458+
"""SSE mode: a handler that emits nothing (here `report_progress` is a no-op because no
459+
`progressToken` was supplied) gets a plain `application/json` response. SDK-defined: SSE only
460+
commits once there is something to stream."""
441461

442462
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
443463
await ctx.session.report_progress(1, total=2)
@@ -447,9 +467,9 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
447467
with anyio.fail_after(5):
448468
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
449469

450-
events = _sse_payloads(response.text)
451-
assert len(events) == 1
452-
assert "result" in events[0]
470+
assert response.status_code == 200
471+
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
472+
assert response.json()["result"]["tools"] == []
453473

454474

455475
async def test_accept_missing_sse_406_in_sse_mode() -> None:
@@ -496,7 +516,7 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
496516
async def test_late_notify_after_terminal_dropped() -> None:
497517
"""SDK-defined: a `notify()` after the SSE sink has closed is silently dropped — the closed
498518
stream must not propagate as an exception out of the dispatch context."""
499-
send_ch, recv_ch = anyio.create_memory_object_stream[dict[str, str]](0)
519+
send_ch, recv_ch = anyio.create_memory_object_stream[bytes](0)
500520
dctx = _SingleExchangeDispatchContext(
501521
transport=TransportContext(kind="streamable-http", can_send_request=False),
502522
request_id=1,
@@ -556,7 +576,7 @@ async def receive() -> Message:
556576
await handler_started.wait()
557577
return {"type": "http.disconnect"}
558578

559-
async def send(message: Message) -> None:
579+
async def send(message: Message) -> None: # pragma: no cover
560580
pass
561581

562582
with anyio.fail_after(5):

0 commit comments

Comments
 (0)