Skip to content

Commit d3b0977

Browse files
committed
Address review: serve_loop helper, bare 405, INVALID_REQUEST split, migration.md fixes
Four review findings, reworked for by-construction: 1. Non-POST -> bare 405 + Allow header (HTTP-layer rejection happens before JSON-RPC parsing, so it doesn't go through the JSON-RPC status table). _write stays table-pure with no override parameter. 2. Stateful HTTP no longer enters lifespan twice. New serve_loop() free function in runner.py owns the loop-mode dispatcher recipe (notably inline_methods={'initialize'}); both Server.run() and the manager's stateful path call it. Manager owns lifespan for all HTTP modes; Server.run() owns it for stdio/memory. Stateful sessions also now have their session_id threaded onto Connection. 3. Well-formed JSON that isn't a single request object (notification, response, batch) returns INVALID_REQUEST instead of PARSE_ERROR. json.loads and model_validate are now distinct steps; INVALID_REQUEST added to ERROR_CODE_HTTP_STATUS. The classifier now receives the decoded body directly rather than a reconstructed dict. 4. migration.md: fixed the broken ctx.connection example (replaced with prose; the high-level Context surface for this is being finalised), documented stateless kwarg + StatelessModeNotSupported removals, updated the ServerSession constructor example, and extended the lifespan-once entry to cover stateful as well.
1 parent 2313cde commit d3b0977

8 files changed

Lines changed: 120 additions & 67 deletions

File tree

docs/migration.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -501,17 +501,17 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=Tru
501501

502502
If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor.
503503

504-
### `stateless_http=True`: lifespan now entered once at startup
504+
### Streamable HTTP: lifespan now entered once at manager startup
505505

506-
When serving streamable HTTP with `stateless_http=True`, the server's `lifespan` context manager is now entered once when `StreamableHTTPSessionManager.run()` starts, and the resulting state is shared across all requests. Previously each incoming request entered (and exited) `lifespan` independently.
506+
When serving streamable HTTP (stateful or `stateless_http=True`), the server's `lifespan` context manager is now entered once when `StreamableHTTPSessionManager.run()` starts, and the resulting state is shared across all sessions and requests. Previously each session (stateful) or each request (stateless) entered and exited `lifespan` independently.
507507

508-
Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of on every request. If your lifespan was acquiring per-connection resources, move that into the handler and register cleanup on the per-connection `exit_stack`:
508+
Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of per session/request. If your lifespan was acquiring per-connection resources, move that acquisition into the handler body; per-connection cleanup belongs on the connection's `exit_stack` (the public surface for reaching it from high-level `@mcp.tool()` handlers is being finalised as part of the public-surface review).
509509

510-
```python
511-
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
512-
db = await ctx.connection.exit_stack.enter_async_context(open_db())
513-
...
514-
```
510+
### `Server.run()` no longer takes a `stateless` flag; `StatelessModeNotSupported` removed
511+
512+
The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready `Connection` per request), not a flag the loop driver inspects.
513+
514+
`StatelessModeNotSupported` has been removed. Server-initiated requests that have no channel to travel on now raise `NoBackChannelError` (an `MCPError` subclass) — the same exception regardless of why the channel is absent. If you were catching `StatelessModeNotSupported`, catch `NoBackChannelError` instead.
515515

516516
### `MCPServer.get_context()` removed
517517

@@ -1228,8 +1228,8 @@ from mcp.server import ServerRequestContext
12281228
session = ServerSession(read_stream, write_stream, init_options, stateless=False)
12291229

12301230
# After (v2)
1231-
session = ServerSession(dispatcher, connection, stateless=False)
1232-
# where `dispatcher` is a JSONRPCDispatcher and `connection` is a Connection
1231+
session = ServerSession(request_outbound, connection)
1232+
# where `request_outbound` is an Outbound and `connection` is a Connection
12331233
```
12341234

12351235
In practice, replace direct `ServerSession` use with `Server.run(read_stream, write_stream, init_options)` and let the framework wire it up.

src/mcp/server/_streamable_http_modern.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
3434
from mcp.shared.transport_context import TransportContext
3535
from mcp.types import (
36-
METHOD_NOT_FOUND,
36+
INVALID_REQUEST,
3737
PARSE_ERROR,
3838
ClientCapabilities,
3939
ErrorData,
@@ -148,23 +148,34 @@ async def handle_modern_request(
148148
# TODO(D-005a): validate Accept once the JSON-vs-SSE response mode is settled.
149149

150150
if request.method != "POST":
151-
rej = JSONRPCError(
152-
jsonrpc="2.0",
153-
id=None,
154-
error=ErrorData(code=METHOD_NOT_FOUND, message=f"HTTP {request.method} not supported on this endpoint"),
155-
)
156-
await _write(rej, scope, receive, send, extra_headers={"Allow": "POST"})
151+
# HTTP-layer rejection (Allow accompanies 405 per RFC 9110) — happens
152+
# before JSON-RPC parsing, so it doesn't go through `_write`.
153+
await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send)
157154
return
158155

159156
body = await request.body()
160157
try:
161-
req = JSONRPCRequest.model_validate_json(body)
162-
except ValidationError:
158+
decoded = json.loads(body)
159+
except json.JSONDecodeError:
163160
rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
164161
await _write(rej, scope, receive, send)
165162
return
163+
try:
164+
req = JSONRPCRequest.model_validate(decoded)
165+
except ValidationError:
166+
# Well-formed JSON that isn't a single request object (a notification,
167+
# a response, a batch). TODO(L57): the 2026 HTTP path carries no
168+
# client→server notifications (cancellation is via SSE close), so a
169+
# plain rejection is currently sufficient.
170+
rej = JSONRPCError(
171+
jsonrpc="2.0",
172+
id=None,
173+
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request object"),
174+
)
175+
await _write(rej, scope, receive, send)
176+
return
166177

167-
verdict = classify_inbound_request({"method": req.method, "params": req.params}, headers=dict(request.headers))
178+
verdict = classify_inbound_request(decoded, headers=dict(request.headers))
168179
if isinstance(verdict, InboundLadderRejection):
169180
rej = JSONRPCError(
170181
jsonrpc="2.0", id=req.id, error=ErrorData(code=verdict.code, message=verdict.message, data=verdict.data)

src/mcp/server/lowlevel/server.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,14 @@ async def main():
5656
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
5757
from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes
5858
from mcp.server.auth.settings import AuthSettings
59-
from mcp.server.connection import Connection
6059
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
6160
from mcp.server.models import InitializationOptions
62-
from mcp.server.runner import serve_connection
61+
from mcp.server.runner import serve_loop
6362
from mcp.server.streamable_http import EventStore
6463
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
6564
from mcp.server.transport_security import TransportSecuritySettings
6665
from mcp.shared._stream_protocols import ReadStream, WriteStream
67-
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
6866
from mcp.shared.message import SessionMessage
69-
from mcp.shared.transport_context import TransportContext
7067
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
7168

7269
logger = logging.getLogger(__name__)
@@ -442,27 +439,18 @@ async def run(
442439
) -> None:
443440
"""Serve a single connection over the given streams until the read side closes.
444441
445-
Thin wrapper over `serve_connection` (L28): enters the server lifespan,
446-
builds a `JSONRPCDispatcher` over the streams and a loop-mode
447-
`Connection` on it, then drives the dispatcher to completion.
442+
Thin wrapper over `serve_loop` (L28): enters the server lifespan,
443+
then drives the loop. Transports with their own lifespan owner
444+
(the streamable-HTTP manager) call `serve_loop` directly instead.
448445
"""
449446
async with self.lifespan(self) as lifespan_context:
450-
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
447+
await serve_loop(
448+
self,
451449
read_stream,
452450
write_stream,
453-
raise_handler_exceptions=raise_exceptions,
454-
# Handle `initialize` inline so a client that pipelines it with
455-
# the next request (spec says SHOULD NOT, not MUST NOT) sees
456-
# the initialized state instead of failing the init-gate.
457-
inline_methods=frozenset({"initialize"}),
458-
)
459-
connection = Connection.for_loop(dispatcher)
460-
await serve_connection(
461-
self,
462-
dispatcher,
463-
connection=connection,
464451
lifespan_state=lifespan_context,
465452
init_options=initialization_options,
453+
raise_exceptions=raise_exceptions,
466454
)
467455

468456
def streamable_http_app(

src/mcp/server/runner.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@
3030
from mcp.server.models import InitializationOptions
3131
from mcp.server.session import ServerSession
3232
from mcp.shared._otel import extract_trace_context, otel_span
33+
from mcp.shared._stream_protocols import ReadStream, WriteStream
3334
from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest
3435
from mcp.shared.exceptions import MCPError
35-
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data
36-
from mcp.shared.message import ServerMessageMetadata
36+
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data
37+
from mcp.shared.message import ServerMessageMetadata, SessionMessage
3738
from mcp.shared.transport_context import TransportContext
3839
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
3940
from mcp.types import (
@@ -436,6 +437,39 @@ async def serve_connection(
436437
await aclose_shielded(connection)
437438

438439

440+
async def serve_loop(
441+
server: Server[LifespanT],
442+
read_stream: ReadStream[SessionMessage | Exception],
443+
write_stream: WriteStream[SessionMessage],
444+
*,
445+
lifespan_state: LifespanT,
446+
session_id: str | None = None,
447+
init_options: InitializationOptions | None = None,
448+
raise_exceptions: bool = False,
449+
) -> None:
450+
"""Drive ``server`` in loop mode over a stream pair until the channel closes.
451+
452+
Builds the loop-mode `JSONRPCDispatcher` + `Connection` and hands them to
453+
`serve_connection`, so the dispatcher-construction recipe (notably the
454+
`inline_methods={"initialize"}` rule) lives in one place. Callers that own
455+
a lifespan (the streamable-HTTP manager) pass it in; callers that don't
456+
(`Server.run` for stdio/memory) enter the lifespan and then call this.
457+
"""
458+
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
459+
read_stream,
460+
write_stream,
461+
raise_handler_exceptions=raise_exceptions,
462+
# Handle `initialize` inline so a client that pipelines it with the
463+
# next request (spec: SHOULD NOT, not MUST NOT) sees the initialized
464+
# state instead of failing the init-gate.
465+
inline_methods=frozenset({"initialize"}),
466+
)
467+
connection = Connection.for_loop(dispatcher, session_id=session_id)
468+
await serve_connection(
469+
server, dispatcher, connection=connection, lifespan_state=lifespan_state, init_options=init_options
470+
)
471+
472+
439473
async def serve_one(
440474
server: Server[LifespanT],
441475
request: JSONRPCRequest,

src/mcp/server/streamable_http_manager.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from mcp.server._streamable_http_modern import handle_modern_request
1818
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
1919
from mcp.server.connection import Connection
20-
from mcp.server.runner import serve_connection
20+
from mcp.server.runner import serve_connection, serve_loop
2121
from mcp.server.streamable_http import (
2222
MCP_PROTOCOL_VERSION_HEADER,
2323
MCP_SESSION_ID_HEADER,
@@ -296,7 +296,7 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
296296
task_status.started()
297297
try:
298298
# Use a cancel scope for idle timeout — when the
299-
# deadline passes the scope cancels app.run() and
299+
# deadline passes the scope cancels the loop and
300300
# execution continues after the ``with`` block.
301301
# Incoming requests push the deadline forward.
302302
idle_scope = anyio.CancelScope()
@@ -305,10 +305,15 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
305305
http_transport.idle_scope = idle_scope
306306

307307
with idle_scope:
308-
await self.app.run(
308+
# Drive via `serve_loop` (not `Server.run()`) so the
309+
# manager's already-entered lifespan is reused
310+
# rather than re-entered per session.
311+
await serve_loop(
312+
self.app,
309313
read_stream,
310314
write_stream,
311-
self.app.create_initialization_options(),
315+
lifespan_state=self._lifespan_state,
316+
session_id=http_transport.mcp_session_id,
312317
)
313318

314319
if idle_scope.cancelled_caught:

src/mcp/shared/inbound.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from mcp.types.jsonrpc import (
2323
HEADER_MISMATCH,
2424
INVALID_PARAMS,
25+
INVALID_REQUEST,
2526
METHOD_NOT_FOUND,
2627
MISSING_REQUIRED_CLIENT_CAPABILITY,
2728
PARSE_ERROR,
@@ -42,6 +43,7 @@
4243
ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType(
4344
{
4445
PARSE_ERROR: 400,
46+
INVALID_REQUEST: 400,
4547
INVALID_PARAMS: 400,
4648
HEADER_MISMATCH: 400,
4749
MISSING_REQUIRED_CLIENT_CAPABILITY: 400,

tests/server/test_streamable_http_manager.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,12 @@ async def running_manager():
103103

104104
@pytest.mark.anyio
105105
async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[StreamableHTTPSessionManager, Server]):
106-
manager, app = running_manager
106+
manager, _app = running_manager
107107

108-
mock_mcp_run = AsyncMock(return_value=None)
109-
# This will be called by StreamableHTTPSessionManager's run_server -> self.app.run
110-
app.run = mock_mcp_run
108+
# The manager's `run_server` task drives `serve_loop` directly (the manager
109+
# owns lifespan); patch that seam so the loop returns immediately and we
110+
# can observe the cleanup that follows.
111+
mock_serve = AsyncMock(return_value=None)
111112

112113
sent_messages: list[Message] = []
113114

@@ -125,7 +126,8 @@ async def mock_receive(): # pragma: no cover
125126
return {"type": "http.request", "body": b"", "more_body": False}
126127

127128
# Trigger session creation
128-
await manager.handle_request(scope, mock_receive, mock_send)
129+
with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve):
130+
await manager.handle_request(scope, mock_receive, mock_send)
129131

130132
# Extract session ID from response headers
131133
session_id = None
@@ -140,10 +142,9 @@ async def mock_receive(): # pragma: no cover
140142

141143
assert session_id is not None, "Session ID not found in response headers"
142144

143-
# Ensure MCPServer.run was called
144-
mock_mcp_run.assert_called_once()
145+
mock_serve.assert_called_once()
145146

146-
# At this point, mock_mcp_run has completed, and the finally block in
147+
# At this point, mock_serve has completed, and the finally block in
147148
# StreamableHTTPSessionManager's run_server should have executed.
148149

149150
# To ensure the task spawned by handle_request finishes and cleanup occurs:
@@ -158,10 +159,9 @@ async def mock_receive(): # pragma: no cover
158159

159160
@pytest.mark.anyio
160161
async def test_stateful_session_cleanup_on_exception(running_manager: tuple[StreamableHTTPSessionManager, Server]):
161-
manager, app = running_manager
162+
manager, _app = running_manager
162163

163-
mock_mcp_run = AsyncMock(side_effect=TestException("Simulated crash"))
164-
app.run = mock_mcp_run
164+
mock_serve = AsyncMock(side_effect=TestException("Simulated crash"))
165165

166166
sent_messages: list[Message] = []
167167

@@ -184,7 +184,8 @@ async def mock_receive(): # pragma: no cover
184184
return {"type": "http.request", "body": b"", "more_body": False}
185185

186186
# Trigger session creation
187-
await manager.handle_request(scope, mock_receive, mock_send)
187+
with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve):
188+
await manager.handle_request(scope, mock_receive, mock_send)
188189

189190
session_id = None
190191
for msg in sent_messages: # pragma: no branch
@@ -198,7 +199,7 @@ async def mock_receive(): # pragma: no cover
198199

199200
assert session_id is not None, "Session ID not found in response headers"
200201

201-
mock_mcp_run.assert_called_once()
202+
mock_serve.assert_called_once()
202203

203204
# Give other tasks a chance to run to ensure the finally block executes
204205
await anyio.sleep(0.01)

tests/server/test_streamable_http_modern.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from mcp.types import (
2525
CLIENT_CAPABILITIES_META_KEY,
2626
CLIENT_INFO_META_KEY,
27-
METHOD_NOT_FOUND,
27+
INVALID_REQUEST,
2828
PARSE_ERROR,
2929
PROTOCOL_VERSION_META_KEY,
3030
ListToolsResult,
@@ -60,17 +60,29 @@ async def app(scope: Scope, receive: Receive, send: Send) -> None:
6060
)
6161

6262

63-
async def test_handle_modern_request_rejects_non_post_with_method_not_found() -> None:
64-
"""A GET on the modern entry is rejected with HTTP 404 and a ``METHOD_NOT_FOUND`` JSON-RPC error.
65-
66-
SDK-defined: the 404 status comes from the SDK's error-code→HTTP-status table; spec-mandated: the
67-
body carries the JSON-RPC ``METHOD_NOT_FOUND`` code.
68-
"""
63+
async def test_handle_modern_request_rejects_non_post_with_http_405_and_allow_header() -> None:
64+
"""SDK-defined: a GET on the modern entry is an HTTP-verb mismatch — 405 Method Not
65+
Allowed with ``Allow: POST`` per RFC 9110. This is HTTP-layer (before JSON-RPC parsing)
66+
so there is no JSON-RPC body."""
6967
async with _asgi_client(Server("test")) as http:
7068
response = await http.get("/mcp")
71-
assert response.status_code == 404
69+
assert response.status_code == 405
7270
assert response.headers["allow"] == "POST"
73-
assert response.json()["error"]["code"] == METHOD_NOT_FOUND
71+
assert response.content == b""
72+
73+
74+
async def test_handle_modern_request_rejects_a_notification_body_with_invalid_request() -> None:
75+
"""SDK-defined: well-formed JSON that isn't a single JSON-RPC request object (e.g. a
76+
notification, which lacks ``id``) is ``INVALID_REQUEST`` — distinct from ``PARSE_ERROR``,
77+
which is for malformed JSON."""
78+
async with _asgi_client(Server("test")) as http:
79+
response = await http.post(
80+
"/mcp",
81+
content=b'{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}',
82+
headers={"content-type": "application/json"},
83+
)
84+
assert response.status_code == 400
85+
assert response.json()["error"]["code"] == INVALID_REQUEST
7486

7587

7688
async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -> None:

0 commit comments

Comments
 (0)