Skip to content

Commit 08638b8

Browse files
committed
Move the JSON-mode back-channel verdict onto the message metadata
Instead of the transport supplying a `transport_builder` that the session manager has to ferry into the dispatcher, the transport now states its verdict on the message it already emits: ServerMessageMetadata grows `can_send_request`, which StreamableHTTPServerTransport stamps as `mcp_session_id is not None and not is_json_response_enabled`, and the dispatcher's default builder honors it. This drops the public transport_context_for method, the serve_loop keyword, and the manager's stateless special-case builder: both legs are now correct with no wiring at all, and so is a transport driven by hand. The metadata already carries transport facts the dispatcher consumes (related_request_id, on_request_unanswered), so the verdict follows an existing channel rather than opening a new one.
1 parent c8a4bb1 commit 08638b8

7 files changed

Lines changed: 87 additions & 73 deletions

File tree

src/mcp/server/runner.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
import contextvars
1717
import logging
18-
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
18+
from collections.abc import AsyncIterator, Awaitable, Mapping
1919
from contextlib import asynccontextmanager
2020
from dataclasses import KW_ONLY, dataclass, replace
2121
from functools import cached_property, partial
@@ -473,7 +473,6 @@ async def serve_loop(
473473
session_id: str | None = None,
474474
init_options: InitializationOptions | None = None,
475475
raise_exceptions: bool = False,
476-
transport_builder: Callable[[MessageMetadata], TransportContext] | None = None,
477476
) -> None:
478477
"""Drive ``server`` in handshake-only loop mode over a stream pair until the channel closes.
479478
@@ -483,20 +482,10 @@ async def serve_loop(
483482
this; `Server.run` drives `serve_dual_era_loop`, which extends the same
484483
dispatcher recipe (notably the `inline_methods={"initialize"}` rule) with
485484
era routing.
486-
487-
Args:
488-
transport_builder: Builds each inbound message's `TransportContext` from
489-
its `SessionMessage.metadata`. Omitted, every message reads as a full
490-
duplex pipe (`can_send_request=True`) - the truth for a plain stream
491-
pair; a transport whose response cannot carry a server-initiated
492-
request (streamable HTTP in JSON-response mode) passes its own
493-
`StreamableHTTPServerTransport.transport_context_for` so the
494-
request-scoped channel refuses instead of stalling.
495485
"""
496486
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
497487
read_stream,
498488
write_stream,
499-
transport_builder=transport_builder,
500489
raise_handler_exceptions=raise_exceptions,
501490
# Handle `initialize` inline so a client that pipelines it with the
502491
# next request (spec: SHOULD NOT, not MUST NOT) sees the initialized

src/mcp/server/streamable_http.py

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@
4444
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
4545
from mcp.shared._stream_protocols import ReadStream, WriteStream
4646
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
47-
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
48-
from mcp.shared.transport_context import TransportContext
47+
from mcp.shared.message import ServerMessageMetadata, SessionMessage
4948

5049
logger = logging.getLogger(__name__)
5150

@@ -176,9 +175,9 @@ def __init__(
176175
Must contain only visible ASCII characters (0x21-0x7E).
177176
is_json_response_enabled: If True, answer each request POST with a single
178177
JSON body instead of an SSE stream. The body carries
179-
only the response, so `transport_context_for` reports
180-
`can_send_request=False` for it: a server-initiated
181-
request on the request-scoped channel raises
178+
only the response, so this transport marks its messages
179+
as having no request-scoped back-channel: a
180+
server-initiated request on that channel raises
182181
`NoBackChannelError` and request-scoped notifications
183182
are dropped. Default is False.
184183
event_store: Event store for resumability support. If provided,
@@ -218,24 +217,14 @@ def is_terminated(self) -> bool:
218217
"""Check if this transport has been explicitly terminated."""
219218
return self._terminated
220219

221-
def transport_context_for(self, metadata: MessageMetadata) -> TransportContext:
222-
"""Build the `TransportContext` for an inbound message this transport delivered.
223-
224-
The transport owns what a request's response can carry, so it supplies
225-
the loop dispatcher's `transport_builder`. A JSON body holds exactly one
226-
JSON-RPC response, so in JSON-response mode the request-scoped channel has
227-
no room for a server-initiated request and `can_send_request` is `False`:
228-
`ctx.elicit()` raises `NoBackChannelError` at once instead of parking a
229-
waiter no reply can reach. An SSE response streams whatever the handler
230-
emits. The connection's standalone GET stream is a separate channel and
231-
is not described here.
220+
@property
221+
def _request_channel_can_send_request(self) -> bool:
222+
"""Whether the request-scoped channel of a message this transport delivers can carry a
223+
server-initiated request. It cannot in JSON-response mode (the POST is answered with one
224+
JSON body) nor with no session (the client's reply would have no session to land on);
225+
stamped on each message's `ServerMessageMetadata` so any dispatcher's builder reads it.
232226
"""
233-
request = metadata.request_context if isinstance(metadata, ServerMessageMetadata) else None
234-
return TransportContext(
235-
kind="streamable-http",
236-
can_send_request=not self.is_json_response_enabled,
237-
headers=request.headers if isinstance(request, Request) else None,
238-
)
227+
return self.mcp_session_id is not None and not self.is_json_response_enabled
239228

240229
def close_sse_stream(self, request_id: RequestId) -> None:
241230
"""Close SSE connection for a specific request without terminating the stream.
@@ -312,9 +301,14 @@ async def close_standalone_stream_callback() -> None:
312301
close_sse_stream=close_stream_callback,
313302
close_standalone_sse_stream=close_standalone_stream_callback,
314303
on_request_unanswered=end_stream,
304+
can_send_request=self._request_channel_can_send_request,
315305
)
316306
else:
317-
metadata = ServerMessageMetadata(request_context=request, on_request_unanswered=end_stream)
307+
metadata = ServerMessageMetadata(
308+
request_context=request,
309+
on_request_unanswered=end_stream,
310+
can_send_request=self._request_channel_can_send_request,
311+
)
318312

319313
return SessionMessage(message, metadata=metadata)
320314

@@ -582,7 +576,9 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
582576
await response(scope, receive, send)
583577

584578
# Process the message after sending the response
585-
metadata = ServerMessageMetadata(request_context=request)
579+
metadata = ServerMessageMetadata(
580+
request_context=request, can_send_request=self._request_channel_can_send_request
581+
)
586582
session_message = SessionMessage(message, metadata=metadata)
587583
await writer.send(session_message)
588584

@@ -608,6 +604,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
608604
metadata = ServerMessageMetadata(
609605
request_context=request,
610606
on_request_unanswered=partial(self._terminate_unanswered_request, message.id),
607+
can_send_request=self._request_channel_can_send_request,
611608
)
612609
session_message = SessionMessage(message, metadata=metadata)
613610
await writer.send(session_message)
@@ -992,12 +989,6 @@ async def connect(
992989
]:
993990
"""Context manager that provides read and write streams for a connection.
994991
995-
Drive the yielded streams through a `JSONRPCDispatcher` built with
996-
`transport_context_for` as its `transport_builder`, as the session
997-
manager does: that is what makes the request-scoped channel refuse
998-
server-initiated requests in JSON-response mode instead of parking
999-
them where nothing can deliver them.
1000-
1001992
Yields:
1002993
Tuple of (read_stream, write_stream) for bidirectional communication
1003994
"""

src/mcp/server/streamable_http_manager.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import logging
77
from collections import deque
88
from collections.abc import AsyncIterator
9-
from dataclasses import replace
109
from typing import TYPE_CHECKING, Any, Final
1110
from uuid import uuid4
1211

@@ -215,14 +214,11 @@ async def run_stateless_server(*, task_status: TaskStatus[None] = anyio.TASK_STA
215214
read_stream,
216215
write_stream,
217216
inline_methods=frozenset({"initialize"}),
218-
# The transport says what its response stream can carry; on
219-
# top of that, no session ID means the client's reply to a
220-
# server request has nowhere to land, so the request-scoped
221-
# channel refuses (`NoBackChannelError`) even in SSE mode
222-
# while still forwarding notifications.
223-
transport_builder=lambda md: replace(
224-
http_transport.transport_context_for(md), can_send_request=False
225-
),
217+
# No `transport_builder`: with no session ID the transport
218+
# marks each message's request-scoped channel unable to
219+
# carry a server-initiated request (the client's reply has
220+
# nowhere to land), so requests raise `NoBackChannelError`
221+
# while notifications still flow.
226222
)
227223
# Born-ready, no standalone channel: the legacy stateless path
228224
# never opens a GET stream and need not see `initialize`. The
@@ -324,14 +320,13 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
324320
# Drive via `serve_loop` (not `Server.run()`) so the
325321
# manager's already-entered lifespan is reused
326322
# rather than re-entered per session; the transport
327-
# rules on what each request's response can carry.
323+
# marks each message with what its channel can carry.
328324
await serve_loop(
329325
self.app,
330326
read_stream,
331327
write_stream,
332328
lifespan_state=self._lifespan_state,
333329
session_id=http_transport.mcp_session_id,
334-
transport_builder=http_transport.transport_context_for,
335330
)
336331

337332
if idle_scope.cancelled_caught:

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,21 @@ def close(self) -> None:
184184
self._closed = True
185185

186186

187-
def _default_transport_builder(_meta: MessageMetadata) -> TransportContext:
188-
return TransportContext(kind="jsonrpc", can_send_request=True)
187+
def _default_transport_builder(metadata: MessageMetadata) -> TransportContext:
188+
"""The `TransportContext` for a message, honoring the transport's own verdict when it stamps one.
189+
190+
A message reads as riding a full duplex pipe (`can_send_request=True`)
191+
unless the transport that framed it says otherwise on the metadata it
192+
attached: streamable HTTP marks JSON-response-mode and sessionless
193+
messages `ServerMessageMetadata(can_send_request=False)`, so their
194+
request-scoped channel raises `NoBackChannelError` instead of parking a
195+
waiter no reply can reach - with no wiring needed from whoever drives the
196+
streams.
197+
"""
198+
can_send_request = True
199+
if isinstance(metadata, ServerMessageMetadata) and metadata.can_send_request is not None:
200+
can_send_request = metadata.can_send_request
201+
return TransportContext(kind="jsonrpc", can_send_request=can_send_request)
189202

190203

191204
def _shielded_progress(fn: ProgressFnT) -> ProgressFnT:

src/mcp/shared/message.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ class ServerMessageMetadata:
4545
# (e.g. it was cancelled), for a transport whose wire must still end the
4646
# request even though no response is written.
4747
on_request_unanswered: Callable[[], Awaitable[None]] | None = None
48+
# The transport's verdict on whether this message's request-scoped channel
49+
# can deliver a server-initiated request; `None` when the transport does
50+
# not say, which the default `TransportContext` builder reads as True.
51+
can_send_request: bool | None = None
4852

4953

5054
MessageMetadata = ClientMessageMetadata | ServerMessageMetadata | None

tests/server/test_streamable_http_router.py

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import anyio
44
import pytest
55
from mcp_types import JSONRPCMessage, JSONRPCResponse
6-
from starlette.requests import Request
76
from starlette.types import Message, Scope
87

98
from mcp.server.streamable_http import (
@@ -15,8 +14,7 @@
1514
StreamableHTTPServerTransport,
1615
StreamId,
1716
)
18-
from mcp.shared.message import ServerMessageMetadata, SessionMessage
19-
from mcp.shared.transport_context import TransportContext
17+
from mcp.shared.message import SessionMessage
2018

2119

2220
class _PrimingFailingStore(EventStore):
@@ -158,18 +156,3 @@ async def asgi_send(message: Message) -> None:
158156

159157
assert sent[0]["type"] == "http.response.start"
160158
assert sent[0]["status"] == 500
161-
162-
163-
def test_transport_context_reports_response_mode_and_request_headers() -> None:
164-
"""The transport's own verdict: no request-scoped requests in JSON mode, headers off the carried Request."""
165-
json_transport = StreamableHTTPServerTransport(mcp_session_id="sid", is_json_response_enabled=True)
166-
assert json_transport.transport_context_for(None) == TransportContext(
167-
kind="streamable-http", can_send_request=False
168-
)
169-
sse_transport = StreamableHTTPServerTransport(mcp_session_id="sid", is_json_response_enabled=False)
170-
assert sse_transport.transport_context_for(None) == TransportContext(kind="streamable-http", can_send_request=True)
171-
172-
request = Request({"type": "http", "method": "POST", "path": "/", "headers": [(b"x-test", b"1")]})
173-
context = sse_transport.transport_context_for(ServerMessageMetadata(request_context=request))
174-
assert context.headers is not None
175-
assert context.headers["x-test"] == "1"

tests/shared/test_jsonrpc_dispatcher.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,45 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) ->
13291329
assert seen[0] is metadata # the exact object, passed through verbatim
13301330

13311331

1332+
@pytest.mark.anyio
1333+
async def test_transport_stamped_can_send_request_makes_the_request_channel_refuse():
1334+
"""A transport that marks a message `can_send_request=False` on its metadata gets a request-scoped
1335+
channel that raises `NoBackChannelError` immediately - the default builder reads the transport's
1336+
verdict off the message, so no driver has to wire it."""
1337+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
1338+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
1339+
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send)
1340+
outcomes: list[bool | str] = []
1341+
1342+
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
1343+
outcomes.append(ctx.can_send_request)
1344+
try:
1345+
await ctx.send_raw_request("elicitation/create", {})
1346+
except NoBackChannelError as exc:
1347+
outcomes.append(exc.method)
1348+
return {}
1349+
1350+
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
1351+
raise NotImplementedError
1352+
1353+
try:
1354+
async with anyio.create_task_group() as tg:
1355+
await tg.start(server.run, on_request, on_notify)
1356+
await c2s_send.send(
1357+
SessionMessage(
1358+
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params=None),
1359+
metadata=ServerMessageMetadata(can_send_request=False),
1360+
)
1361+
)
1362+
with anyio.fail_after(5):
1363+
await s2c_recv.receive() # response sent => the handler has run
1364+
tg.cancel_scope.cancel()
1365+
finally:
1366+
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
1367+
s.close()
1368+
assert outcomes == [False, "elicitation/create"]
1369+
1370+
13321371
@pytest.mark.anyio
13331372
async def test_ctx_message_metadata_carries_inbound_notification_metadata():
13341373
"""Notifications get the same metadata pass-through as requests."""

0 commit comments

Comments
 (0)