Skip to content

Commit 8d76959

Browse files
committed
Narrow the transport's back-channel verdict to JSON-response mode
The previous commit had the transport also refuse whenever it holds no session id, but a missing session id is not what makes a client's reply unroutable - the session manager creating a fresh transport per request is. A single long-lived sessionless transport still receives the reply on the same instance, so server-initiated requests over SSE kept working there and must continue to. The transport now states only the fact it owns (a JSON body carries one response), and the manager keeps its own can_send_request=False builder for the stateless leg. The manager module is unchanged from main.
1 parent 08638b8 commit 8d76959

3 files changed

Lines changed: 16 additions & 16 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,11 +220,12 @@ def is_terminated(self) -> bool:
220220
@property
221221
def _request_channel_can_send_request(self) -> bool:
222222
"""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.
223+
server-initiated request. It cannot in JSON-response mode: the POST is answered with one
224+
JSON body, which holds only the response. Stamped on each message's
225+
`ServerMessageMetadata` so any dispatcher's builder reads it; whether a reply has a session
226+
to land on is the session manager's fact, not the transport's, so it is not decided here.
226227
"""
227-
return self.mcp_session_id is not None and not self.is_json_response_enabled
228+
return not self.is_json_response_enabled
228229

229230
def close_sse_stream(self, request_id: RequestId) -> None:
230231
"""Close SSE connection for a specific request without terminating the stream.

src/mcp/server/streamable_http_manager.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,12 @@ async def run_stateless_server(*, task_status: TaskStatus[None] = anyio.TASK_STA
214214
read_stream,
215215
write_stream,
216216
inline_methods=frozenset({"initialize"}),
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.
217+
# No session ID means a server-to-client request can be
218+
# written to this POST's response stream, but the client's
219+
# reply has nowhere to land — `can_send_request=False`
220+
# makes the per-request channel raise `NoBackChannelError`
221+
# for requests while still allowing notifications.
222+
transport_builder=lambda _md: TransportContext(kind="streamable-http", can_send_request=False),
222223
)
223224
# Born-ready, no standalone channel: the legacy stateless path
224225
# never opens a GET stream and need not see `initialize`. The
@@ -319,8 +320,7 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
319320
with idle_scope:
320321
# Drive via `serve_loop` (not `Server.run()`) so the
321322
# manager's already-entered lifespan is reused
322-
# rather than re-entered per session; the transport
323-
# marks each message with what its channel can carry.
323+
# rather than re-entered per session.
324324
await serve_loop(
325325
self.app,
326326
read_stream,

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,10 @@ def _default_transport_builder(metadata: MessageMetadata) -> TransportContext:
189189
190190
A message reads as riding a full duplex pipe (`can_send_request=True`)
191191
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.
192+
attached: streamable HTTP marks JSON-response-mode messages
193+
`ServerMessageMetadata(can_send_request=False)`, so their request-scoped
194+
channel raises `NoBackChannelError` instead of parking a waiter no reply
195+
can reach - with no wiring needed from whoever drives the streams.
197196
"""
198197
can_send_request = True
199198
if isinstance(metadata, ServerMessageMetadata) and metadata.can_send_request is not None:

0 commit comments

Comments
 (0)