55path for earlier protocol revisions.
66
77A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no
8- `Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. This
9- module handles such a request directly in the ASGI task - no memory streams,
10- no per-request task group, no `JSONRPCDispatcher`.
8+ `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 and defers committing to `text/event-stream` until the
11+ handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever
12+ comes first: a handler that completes (or raises) within that window without
13+ emitting still gets a JSON response with the table-mapped HTTP status, so
14+ the spec's `404`/`400` MUSTs hold for kernel-dispatch errors; a handler that
15+ runs silent past the window commits SSE so the keepalive ping can keep the
16+ connection open behind a proxy idle-read timeout.
1117"""
1218
1319from __future__ import annotations
1622import logging
1723from collections .abc import Awaitable , Mapping
1824from dataclasses import dataclass , field
19- from typing import TYPE_CHECKING , Any , TypeVar
25+ from typing import TYPE_CHECKING , Any , Final , TypeVar
2026
2127import anyio
28+ from anyio .streams .memory import MemoryObjectSendStream
2229from mcp_types import (
2330 INTERNAL_ERROR ,
2431 INVALID_REQUEST ,
2734 ErrorData ,
2835 Implementation ,
2936 JSONRPCError ,
37+ JSONRPCNotification ,
3038 JSONRPCRequest ,
3139 JSONRPCResponse ,
40+ ProgressToken ,
3241 RequestId ,
3342)
3443from pydantic import BaseModel , ValidationError
3847
3948from mcp .server .connection import Connection
4049from mcp .server .runner import serve_one
50+ from mcp .server .streamable_http import check_accept_headers
4151from mcp .server .transport_security import TransportSecurityMiddleware , TransportSecuritySettings
4252from mcp .shared .dispatcher import CallOptions
4353from mcp .shared .exceptions import NoBackChannelError
4656 InboundLadderRejection ,
4757 classify_inbound_request ,
4858)
49- from mcp .shared .jsonrpc_dispatcher import handler_exception_to_error_data
59+ from mcp .shared .jsonrpc_dispatcher import handler_exception_to_error_data , progress_token_from_params
5060from mcp .shared .message import MessageMetadata , ServerMessageMetadata
5161from mcp .shared .transport_context import TransportContext
5262
@@ -66,12 +76,15 @@ class _SingleExchangeDispatchContext:
6676
6777 Structurally satisfies `mcp.shared.dispatcher.DispatchContext`. The
6878 back-channel is closed by construction: a 2026-07-28 server cannot send
69- requests to the client.
79+ requests to the client. The SSE sink, when present, carries request-scoped
80+ notifications onto this request's response stream.
7081 """
7182
7283 transport : TransportContext
7384 request_id : RequestId
7485 message_metadata : MessageMetadata
86+ progress_token : ProgressToken | None = None
87+ sink : MemoryObjectSendStream [bytes ] | None = None
7588 cancel_requested : anyio .Event = field (default_factory = anyio .Event )
7689 can_send_request : bool = field (default = False , init = False )
7790
@@ -84,12 +97,23 @@ async def send_raw_request(
8497 raise NoBackChannelError (method )
8598
8699 async def notify (self , method : str , params : Mapping [str , Any ] | None , opts : CallOptions | None = None ) -> None :
87- # TODO(D-005a): buffer and stream as SSE once the JSON-vs-SSE response mode lands.
88- return None
100+ if self .sink is None :
101+ return
102+ body = dict (params ) if params is not None else None
103+ try :
104+ await self .sink .send (_sse_event (JSONRPCNotification (jsonrpc = "2.0" , method = method , params = body )))
105+ except (anyio .ClosedResourceError , anyio .BrokenResourceError ):
106+ logger .debug ("dropped %s: response stream closed" , method )
89107
90108 async def progress (self , progress : float , total : float | None = None , message : str | None = None ) -> None :
91- # TODO(D-005a): no progressToken plumbing yet; ships with the SSE response mode.
92- return None
109+ if self .progress_token is None :
110+ return
111+ params : dict [str , Any ] = {"progressToken" : self .progress_token , "progress" : progress }
112+ if total is not None :
113+ params ["total" ] = total
114+ if message is not None :
115+ params ["message" ] = message
116+ await self .notify ("notifications/progress" , params )
93117
94118
95119def _typed (model : type [_ModelT ], raw : Any ) -> _ModelT | None :
@@ -126,6 +150,28 @@ async def _to_jsonrpc_response(
126150 return JSONRPCResponse (jsonrpc = "2.0" , id = request_id , result = result )
127151
128152
153+ _SSE_PING_INTERVAL : float = 15.0
154+ """Seconds between SSE comment-line keepalives once `text/event-stream` has committed."""
155+
156+ _SSE_HEADERS : Final [list [tuple [bytes , bytes ]]] = [
157+ (b"content-type" , b"text/event-stream" ),
158+ (b"cache-control" , b"no-cache, no-transform" ),
159+ (b"connection" , b"keep-alive" ),
160+ (b"x-accel-buffering" , b"no" ),
161+ ]
162+
163+
164+ def _sse_event (msg : JSONRPCResponse | JSONRPCError | JSONRPCNotification ) -> bytes :
165+ """Serialise a JSON-RPC message as one SSE `event: message` frame.
166+
167+ SSE mode begins after the handler has emitted, so a `JSONRPCError` here
168+ always carries the request's id; the `id: null` case lives in `_write`.
169+ """
170+ body = msg .model_dump (mode = "json" , by_alias = True , exclude_none = True )
171+ data = json .dumps (body , separators = ("," , ":" ))
172+ return f"event: message\r \n data: { data } \r \n \r \n " .encode ()
173+
174+
129175async def _write (
130176 msg : JSONRPCResponse | JSONRPCError ,
131177 scope : Scope ,
@@ -149,6 +195,7 @@ async def _write(
149195async def handle_modern_request (
150196 app : Server [Any ],
151197 security_settings : TransportSecuritySettings | None ,
198+ json_response : bool ,
152199 lifespan_state : Any ,
153200 scope : Scope ,
154201 receive : Receive ,
@@ -169,14 +216,17 @@ async def handle_modern_request(
169216 await err (scope , receive , send )
170217 return
171218
172- # TODO(D-005a): validate Accept once the JSON-vs-SSE response mode is settled.
173-
174219 if request .method != "POST" :
175220 # HTTP-layer rejection (Allow accompanies 405 per RFC 9110) — happens
176221 # before JSON-RPC parsing, so it doesn't go through `_write`.
177222 await Response (status_code = 405 , headers = {"Allow" : "POST" })(scope , receive , send )
178223 return
179224
225+ has_json , has_sse = check_accept_headers (request )
226+ if not has_json or (not json_response and not has_sse ):
227+ await Response (status_code = 406 )(scope , receive , send )
228+ return
229+
180230 body = await request .body ()
181231 try :
182232 decoded = json .loads (body )
@@ -219,8 +269,65 @@ async def handle_modern_request(
219269 transport = TransportContext (kind = "streamable-http" , can_send_request = False , headers = request .headers ),
220270 request_id = req .id ,
221271 message_metadata = ServerMessageMetadata (request_context = request ),
272+ progress_token = progress_token_from_params (req .params ),
222273 )
223- msg = await _to_jsonrpc_response (
224- req .id , serve_one (app , dctx , req .method , req .params , connection = connection , lifespan_state = lifespan_state )
225- )
226- await _write (msg , scope , receive , send )
274+
275+ if json_response :
276+ msg = await _to_jsonrpc_response (
277+ req .id , serve_one (app , dctx , req .method , req .params , connection = connection , lifespan_state = lifespan_state )
278+ )
279+ await _write (msg , scope , receive , send )
280+ return
281+
282+ send_ch , recv_ch = anyio .create_memory_object_stream [bytes ](0 )
283+ dctx .sink = send_ch
284+ result : list [JSONRPCResponse | JSONRPCError ] = []
285+
286+ async def run_handler () -> None :
287+ async with send_ch :
288+ result .append (
289+ await _to_jsonrpc_response (
290+ req .id ,
291+ serve_one (app , dctx , req .method , req .params , connection = connection , lifespan_state = lifespan_state ),
292+ )
293+ )
294+
295+ async def watch_disconnect (cancel_scope : anyio .CancelScope ) -> None :
296+ while (await receive ()).get ("type" ) != "http.disconnect" :
297+ pass # pragma: no cover
298+ cancel_scope .cancel ()
299+
300+ async with recv_ch , anyio .create_task_group () as tg :
301+ tg .start_soon (run_handler )
302+ tg .start_soon (watch_disconnect , tg .cancel_scope )
303+
304+ event : bytes | None = None
305+ done = False
306+ with anyio .move_on_after (_SSE_PING_INTERVAL ):
307+ try :
308+ event = await recv_ch .receive ()
309+ except anyio .EndOfStream :
310+ done = True
311+
312+ if done :
313+ # Handler completed within the deferral window without emitting:
314+ # `application/json` with the table-mapped status. Kernel-dispatch
315+ # errors (METHOD_NOT_FOUND, missing-capability, INVALID_PARAMS)
316+ # resolve here in practice.
317+ await _write (result [0 ], scope , receive , send )
318+ else :
319+ # First notification arrived, or the deferral window elapsed: commit
320+ # `text/event-stream` and start pinging so a proxy idle-read timeout
321+ # cannot close the stream (which on this path cancels the handler).
322+ await send ({"type" : "http.response.start" , "status" : _OK_STATUS , "headers" : _SSE_HEADERS })
323+ while not done :
324+ await send ({"type" : "http.response.body" , "body" : event or b": ping\r \n \r \n " , "more_body" : True })
325+ event = None
326+ with anyio .move_on_after (_SSE_PING_INTERVAL ):
327+ try :
328+ event = await recv_ch .receive ()
329+ except anyio .EndOfStream :
330+ done = True
331+ await send ({"type" : "http.response.body" , "body" : _sse_event (result [0 ]), "more_body" : False })
332+
333+ tg .cancel_scope .cancel ()
0 commit comments