Skip to content

Commit f1fa4ec

Browse files
committed
Add experimental 2026-07-28 stateless HTTP serving entry
Routes MCP-Protocol-Version: 2026-07-28 requests at the session-manager seam to a new direct-invocation handler in mcp.server._experimental, leaving the existing 2025-era paths (stateful and stateless_http) unchanged. The new handler builds a fresh per-request ServerRunner over a single-exchange Dispatcher implementation (no memory streams, no JSONRPCDispatcher), pre-commits the connection to 2026-07-28, runs the composed on_request directly in the request task, and writes a JSON response. Server-to-client requests raise NoBackChannelError; notifications no-op pending SSE streaming. Dispatcher annotations on ServerRunner/ServerSession widened from JSONRPCDispatcher to the Dispatcher Protocol. The module is experimental and not part of the public API. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
1 parent 9e6d003 commit f1fa4ec

5 files changed

Lines changed: 244 additions & 10 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Experimental, unstable. No public API; may change or vanish without deprecation."""
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Experimental, unstable. Single-exchange HTTP serving for protocol version 2026-07-28.
2+
3+
No public API; everything in this module may change or vanish without
4+
deprecation. The legacy streamable-HTTP transport is untouched and remains the
5+
supported entry point.
6+
7+
A 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`.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import logging
16+
from collections.abc import Mapping
17+
from dataclasses import dataclass, field
18+
from typing import TYPE_CHECKING, Any, Final
19+
20+
import anyio
21+
import anyio.abc
22+
from pydantic import ValidationError
23+
from starlette.requests import Request
24+
from starlette.responses import Response
25+
from starlette.types import Receive, Scope, Send
26+
27+
from mcp.server.runner import ServerRunner, otel_middleware
28+
from mcp.server.transport_security import TransportSecurityMiddleware
29+
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest
30+
from mcp.shared.exceptions import MCPError, NoBackChannelError
31+
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
32+
from mcp.shared.transport_context import TransportContext
33+
from mcp.types import (
34+
INTERNAL_ERROR,
35+
INVALID_PARAMS,
36+
PARSE_ERROR,
37+
ErrorData,
38+
JSONRPCError,
39+
JSONRPCRequest,
40+
JSONRPCResponse,
41+
RequestId,
42+
)
43+
44+
if TYPE_CHECKING:
45+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
46+
47+
logger = logging.getLogger(__name__)
48+
49+
MODERN_PROTOCOL_VERSION: Final[str] = "2026-07-28"
50+
"""The protocol version this module serves. Kept local so it does not leak into
51+
`SUPPORTED_PROTOCOL_VERSIONS` or the legacy handshake."""
52+
53+
54+
@dataclass
55+
class _SingleExchangeDispatchContext:
56+
"""`DispatchContext` for one inbound HTTP request.
57+
58+
Structurally satisfies `mcp.shared.dispatcher.DispatchContext`. The
59+
back-channel is closed by construction: a 2026-07-28 server cannot send
60+
requests to the client.
61+
"""
62+
63+
transport: TransportContext
64+
request_id: RequestId
65+
message_metadata: MessageMetadata
66+
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
67+
can_send_request: bool = False
68+
69+
async def send_raw_request(
70+
self,
71+
method: str,
72+
params: Mapping[str, Any] | None,
73+
opts: CallOptions | None = None,
74+
) -> dict[str, Any]:
75+
raise NoBackChannelError(method)
76+
77+
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
78+
return None
79+
80+
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
81+
# TODO: no progressToken plumbing yet.
82+
return None
83+
84+
85+
class SingleExchangeDispatcher:
86+
"""Dispatcher for exactly one inbound JSON-RPC request over a single HTTP POST.
87+
88+
The exception->wire boundary lives here (mirrors `JSONRPCDispatcher`'s
89+
role). Implements the `Dispatcher` Protocol so `ServerRunner` /
90+
`Connection` / `ServerSession` accept it; `run()` is never driven.
91+
"""
92+
93+
def __init__(self, request: Request) -> None:
94+
self._request = request
95+
self._tctx = TransportContext(
96+
kind="streamable-http",
97+
can_send_request=False,
98+
headers=request.headers,
99+
)
100+
101+
async def send_raw_request(
102+
self,
103+
method: str,
104+
params: Mapping[str, Any] | None,
105+
opts: CallOptions | None = None,
106+
*,
107+
_related_request_id: RequestId | None = None,
108+
) -> dict[str, Any]:
109+
raise NoBackChannelError(method)
110+
111+
async def notify(
112+
self,
113+
method: str,
114+
params: Mapping[str, Any] | None,
115+
*,
116+
_related_request_id: RequestId | None = None,
117+
) -> None:
118+
# TODO: buffer and stream as SSE once the response-mode design lands.
119+
return None
120+
121+
async def run(
122+
self,
123+
on_request: OnRequest,
124+
on_notify: OnNotify,
125+
*,
126+
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
127+
) -> None:
128+
raise RuntimeError("SingleExchangeDispatcher.run() is never driven; use handle()")
129+
130+
async def handle(self, req: JSONRPCRequest, on_request: OnRequest) -> JSONRPCResponse | JSONRPCError:
131+
"""Dispatch one request and map any exception to a `JSONRPCError`."""
132+
dctx = _SingleExchangeDispatchContext(
133+
transport=self._tctx,
134+
request_id=req.id,
135+
message_metadata=ServerMessageMetadata(request_context=self._request),
136+
)
137+
try:
138+
result = await on_request(dctx, req.method, req.params)
139+
return JSONRPCResponse(jsonrpc="2.0", id=req.id, result=result)
140+
except MCPError as e:
141+
return JSONRPCError(jsonrpc="2.0", id=req.id, error=e.error)
142+
except ValidationError:
143+
return JSONRPCError(
144+
jsonrpc="2.0",
145+
id=req.id,
146+
error=ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""),
147+
)
148+
# TODO: consolidate the three exception->ErrorData copies once the
149+
# code=0 compat pin in JSONRPCDispatcher is lifted.
150+
except Exception:
151+
logger.exception("handler for %r raised", req.method)
152+
return JSONRPCError(
153+
jsonrpc="2.0",
154+
id=req.id,
155+
error=ErrorData(code=INTERNAL_ERROR, message="Internal server error"),
156+
)
157+
158+
159+
async def handle_modern_request(
160+
manager: StreamableHTTPSessionManager,
161+
scope: Scope,
162+
receive: Receive,
163+
send: Send,
164+
) -> None:
165+
"""ASGI handler for a single 2026-07-28 POST.
166+
167+
Called from `StreamableHTTPSessionManager.handle_request` when the
168+
`MCP-Protocol-Version` header is `2026-07-28`. Never sets `Mcp-Session-Id`.
169+
"""
170+
request = Request(scope, receive)
171+
172+
security = TransportSecurityMiddleware(manager.security_settings)
173+
err = await security.validate_request(request, is_post=(request.method == "POST"))
174+
if err is not None:
175+
await err(scope, receive, send)
176+
return
177+
178+
# TODO: validate Accept header once the JSON-vs-SSE response-mode design is settled.
179+
180+
if request.method != "POST":
181+
# TODO: GET/DELETE rejection (405 + -32601) lands with the validation ladder.
182+
await Response(status_code=405)(scope, receive, send)
183+
return
184+
185+
body = await request.body()
186+
try:
187+
req = JSONRPCRequest.model_validate_json(body)
188+
except ValidationError:
189+
msg = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
190+
await Response(
191+
msg.model_dump_json(by_alias=True, exclude_none=True),
192+
status_code=400,
193+
media_type="application/json",
194+
)(scope, receive, send)
195+
return
196+
197+
dispatcher = SingleExchangeDispatcher(request)
198+
# TODO: per-request lifespan re-entry matches stateless_http=True today; revisit in #2893.
199+
async with manager.app.lifespan(manager.app) as lifespan_state:
200+
runner = ServerRunner(
201+
server=manager.app,
202+
dispatcher=dispatcher,
203+
lifespan_state=lifespan_state,
204+
has_standalone_channel=False,
205+
stateless=True,
206+
dispatch_middleware=[otel_middleware],
207+
)
208+
runner.connection.protocol_version = MODERN_PROTOCOL_VERSION
209+
try:
210+
msg = await dispatcher.handle(req, runner._compose_on_request()) # type: ignore[reportPrivateUsage]
211+
finally:
212+
await runner.connection.exit_stack.aclose()
213+
214+
# TODO: error.code -> HTTP status mapping is a follow-up; 200 for all JSONRPCError bodies for now.
215+
await Response(
216+
msg.model_dump_json(by_alias=True, exclude_none=True),
217+
status_code=200,
218+
media_type="application/json",
219+
)(scope, receive, send)

src/mcp/server/runner.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,8 @@
3131
from mcp.server.models import InitializationOptions
3232
from mcp.server.session import ServerSession
3333
from mcp.shared._otel import extract_trace_context, otel_span
34-
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
34+
from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnRequest
3535
from mcp.shared.exceptions import MCPError
36-
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
3736
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
3837
from mcp.shared.transport_context import TransportContext
3938
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
@@ -175,7 +174,7 @@ class ServerRunner(Generic[LifespanT]):
175174
"""Per-connection orchestrator. One instance per client connection."""
176175

177176
server: Server[LifespanT]
178-
dispatcher: JSONRPCDispatcher[Any]
177+
dispatcher: Dispatcher[Any]
179178
lifespan_state: LifespanT
180179
has_standalone_channel: bool
181180
init_options: InitializationOptions | None = None

src/mcp/server/session.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,15 @@
99
used to live here are now owned by `JSONRPCDispatcher` and `ServerRunner`.
1010
"""
1111

12-
from typing import Any, TypeVar, overload
12+
from typing import Any, TypeVar, cast, overload
1313

1414
from pydantic import AnyUrl, BaseModel
1515

1616
from mcp import types
1717
from mcp.server.connection import Connection
1818
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
19-
from mcp.shared.dispatcher import CallOptions, ProgressFnT
19+
from mcp.shared.dispatcher import CallOptions, Dispatcher, ProgressFnT
2020
from mcp.shared.exceptions import NoBackChannelError, StatelessModeNotSupported
21-
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
2221
from mcp.shared.message import ServerMessageMetadata
2322
from mcp.types import methods as _methods
2423

@@ -37,7 +36,7 @@ class ServerSession:
3736

3837
def __init__(
3938
self,
40-
dispatcher: JSONRPCDispatcher[Any],
39+
dispatcher: Dispatcher[Any],
4140
connection: Connection,
4241
*,
4342
stateless: bool = False,
@@ -92,8 +91,16 @@ async def send_request(
9291
# Fail fast instead of parking forever on a response that cannot
9392
# arrive; matches `Connection.send_raw_request`.
9493
raise NoBackChannelError(data["method"])
95-
result = await self._dispatcher.send_raw_request(
96-
data["method"], data.get("params"), opts or None, _related_request_id=related
94+
# TODO: _related_request_id is not on the Dispatcher Protocol; either
95+
# add it there or refactor ServerSession once the legacy path is compat-only.
96+
result = cast(
97+
"dict[str, Any]",
98+
await self._dispatcher.send_raw_request(
99+
data["method"],
100+
data.get("params"),
101+
opts or None,
102+
_related_request_id=related, # type: ignore[call-arg]
103+
),
97104
)
98105
# Literal fallback covers pre-handshake and stateless; matches runner.py.
99106
version = self.protocol_version or "2025-11-25"
@@ -110,7 +117,7 @@ async def send_notification(
110117
) -> None:
111118
"""Send a typed server-to-client notification."""
112119
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
113-
await self._dispatcher.notify(data["method"], data.get("params"), _related_request_id=related_request_id)
120+
await self._dispatcher.notify(data["method"], data.get("params"), _related_request_id=related_request_id) # type: ignore[call-arg]
114121

115122
def check_client_capability(self, capability: types.ClientCapabilities) -> bool:
116123
"""Check if the client supports a specific capability."""

src/mcp/server/streamable_http_manager.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from starlette.responses import Response
1515
from starlette.types import Receive, Scope, Send
1616

17+
from mcp.server._experimental.streamable_http_modern import handle_modern_request
1718
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
1819
from mcp.server.streamable_http import (
1920
MCP_SESSION_ID_HEADER,
@@ -150,6 +151,13 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No
150151
if self._task_group is None:
151152
raise RuntimeError("Task group is not initialized. Make sure to use run().")
152153

154+
# TODO: header-only routing for now; body-primary classification
155+
# (per SEP-2575) is a follow-up. 2025 paths below remain unchanged.
156+
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == b"mcp-protocol-version"), None)
157+
if pv == "2026-07-28":
158+
await handle_modern_request(self, scope, receive, send)
159+
return
160+
153161
# Dispatch to the appropriate handler
154162
if self.stateless:
155163
await self._handle_stateless_request(scope, receive, send)

0 commit comments

Comments
 (0)