Skip to content

Commit ca1a848

Browse files
committed
Stamp the JSON-mode verdict in one metadata factory
Route every ServerMessageMetadata the transport frames through a single _message_metadata factory so the can_send_request stamp has one home a future construction site cannot forget. The field becomes a plain bool defaulting to True, dropping the None sentinel and the branch that read it. Docstrings now defer to TransportContext.can_send_request for the full rule instead of restating it, and the two direct-transport tests share one fake-ASGI scaffold.
1 parent 8d76959 commit ca1a848

5 files changed

Lines changed: 83 additions & 106 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 37 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +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 ServerMessageMetadata, SessionMessage
47+
from mcp.shared.message import CloseSSEStreamCallback, ServerMessageMetadata, SessionMessage
4848

4949
logger = logging.getLogger(__name__)
5050

@@ -174,12 +174,11 @@ def __init__(
174174
mcp_session_id: Optional session identifier for this connection.
175175
Must contain only visible ASCII characters (0x21-0x7E).
176176
is_json_response_enabled: If True, answer each request POST with a single
177-
JSON body instead of an SSE stream. The body carries
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
181-
`NoBackChannelError` and request-scoped notifications
182-
are dropped. Default is False.
177+
JSON body instead of an SSE stream, which removes
178+
the request-scoped back-channel: a server-initiated
179+
request tied to the call raises `NoBackChannelError`
180+
and its notifications are dropped (see
181+
`TransportContext.can_send_request`). Default is False.
183182
event_store: Event store for resumability support. If provided,
184183
resumability will be enabled, allowing clients to
185184
reconnect and resume messages.
@@ -217,15 +216,28 @@ def is_terminated(self) -> bool:
217216
"""Check if this transport has been explicitly terminated."""
218217
return self._terminated
219218

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, 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.
219+
def _message_metadata(
220+
self,
221+
request: Request,
222+
*,
223+
close_sse_stream: CloseSSEStreamCallback | None = None,
224+
close_standalone_sse_stream: CloseSSEStreamCallback | None = None,
225+
on_request_unanswered: Callable[[], Awaitable[None]] | None = None,
226+
) -> ServerMessageMetadata:
227+
"""The metadata this transport frames every inbound message with.
228+
229+
The one place `can_send_request` is stamped, so no construction site can
230+
forget it: a JSON body carries only the response, so in JSON-response mode
231+
the request-scoped channel cannot carry a server-initiated request (see
232+
`TransportContext.can_send_request`).
227233
"""
228-
return not self.is_json_response_enabled
234+
return ServerMessageMetadata(
235+
request_context=request,
236+
close_sse_stream=close_sse_stream,
237+
close_standalone_sse_stream=close_standalone_sse_stream,
238+
on_request_unanswered=on_request_unanswered,
239+
can_send_request=not self.is_json_response_enabled,
240+
)
229241

230242
def close_sse_stream(self, request_id: RequestId) -> None:
231243
"""Close SSE connection for a specific request without terminating the stream.
@@ -297,19 +309,14 @@ async def close_stream_callback() -> None:
297309
async def close_standalone_stream_callback() -> None:
298310
self.close_standalone_sse_stream()
299311

300-
metadata = ServerMessageMetadata(
301-
request_context=request,
312+
metadata = self._message_metadata(
313+
request,
302314
close_sse_stream=close_stream_callback,
303315
close_standalone_sse_stream=close_standalone_stream_callback,
304316
on_request_unanswered=end_stream,
305-
can_send_request=self._request_channel_can_send_request,
306317
)
307318
else:
308-
metadata = ServerMessageMetadata(
309-
request_context=request,
310-
on_request_unanswered=end_stream,
311-
can_send_request=self._request_channel_can_send_request,
312-
)
319+
metadata = self._message_metadata(request, on_request_unanswered=end_stream)
313320

314321
return SessionMessage(message, metadata=metadata)
315322

@@ -577,10 +584,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
577584
await response(scope, receive, send)
578585

579586
# Process the message after sending the response
580-
metadata = ServerMessageMetadata(
581-
request_context=request, can_send_request=self._request_channel_can_send_request
582-
)
583-
session_message = SessionMessage(message, metadata=metadata)
587+
session_message = SessionMessage(message, metadata=self._message_metadata(request))
584588
await writer.send(session_message)
585589

586590
return
@@ -602,10 +606,8 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
602606
)
603607
request_stream_reader = self._request_streams[request_id][1]
604608
# Process the message
605-
metadata = ServerMessageMetadata(
606-
request_context=request,
607-
on_request_unanswered=partial(self._terminate_unanswered_request, message.id),
608-
can_send_request=self._request_channel_can_send_request,
609+
metadata = self._message_metadata(
610+
request, on_request_unanswered=partial(self._terminate_unanswered_request, message.id)
609611
)
610612
session_message = SessionMessage(message, metadata=metadata)
611613
await writer.send(session_message)
@@ -1030,15 +1032,10 @@ async def message_router():
10301032
):
10311033
related_request_id = session_message.metadata.related_request_id
10321034
if self.is_json_response_enabled:
1033-
# A JSON body carries exactly the one response: a
1034-
# message that only rides this request's stream
1035-
# has no wire form (and no POST could replay it),
1036-
# so drop it before storing or queueing - never
1037-
# park it in a queue nothing drains (#1764).
1038-
logger.debug(
1039-
f"Dropped message related to request {related_request_id}: "
1040-
"a JSON response carries only its own response"
1041-
)
1035+
# A JSON body carries only the response: this message
1036+
# has no wire form (nor a replay), so drop it before
1037+
# storing or queueing rather than park it (#1764).
1038+
logger.debug(f"Dropped message related to request {related_request_id} in JSON mode")
10421039
continue
10431040
target_request_id = str(related_request_id)
10441041

src/mcp/shared/exceptions.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,10 @@ def __str__(self) -> str:
5555
class NoBackChannelError(MCPError):
5656
"""Raised when a server-initiated request has no channel that can deliver it.
5757
58-
The request-scoped channel is a dead end when the response has no room
59-
(streamable HTTP in JSON-response mode, the 2026-07-28 single-exchange
60-
entry), when the client's reply has nowhere to land (stateless HTTP), or
61-
when the protocol forbids server-initiated requests (2026-07-28). Raised by
62-
`DispatchContext.send_raw_request` when `can_send_request` is `False`, and
63-
by a connection's standalone channel when it has none; serializes to an
64-
`INVALID_REQUEST` error response.
58+
Raised by `DispatchContext.send_raw_request` when its request-scoped channel
59+
reports `TransportContext.can_send_request` as `False` (the cases are
60+
documented on that field), and by a connection's standalone channel when it
61+
has none; serializes to an `INVALID_REQUEST` error response.
6562
"""
6663

6764
def __init__(self, method: str):

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,11 @@ 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 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.
192+
attached, so a transport whose response has no room for a server request
193+
(streamable HTTP in JSON-response mode) needs no wiring from whoever drives
194+
its streams.
196195
"""
197-
can_send_request = True
198-
if isinstance(metadata, ServerMessageMetadata) and metadata.can_send_request is not None:
199-
can_send_request = metadata.can_send_request
196+
can_send_request = metadata.can_send_request if isinstance(metadata, ServerMessageMetadata) else True
200197
return TransportContext(kind="jsonrpc", can_send_request=can_send_request)
201198

202199

src/mcp/shared/message.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ class ServerMessageMetadata:
4646
# request even though no response is written.
4747
on_request_unanswered: Callable[[], Awaitable[None]] | None = None
4848
# 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
49+
# can deliver a server-initiated request (see
50+
# `TransportContext.can_send_request`); a transport that says nothing leaves
51+
# it True.
52+
can_send_request: bool = True
5253

5354

5455
MessageMetadata = ClientMessageMetadata | ServerMessageMetadata | None

tests/server/test_streamable_http_router.py

Lines changed: 34 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,25 @@ async def replay_events_after(self, last_event_id: EventId, send_callback: Event
2525
raise NotImplementedError
2626

2727

28+
class _AsgiPost:
29+
"""A one-shot POST driven straight at `handle_request`, capturing what the transport sends."""
30+
31+
def __init__(self, body: bytes, headers: list[tuple[bytes, bytes]]) -> None:
32+
self.scope: Scope = {"type": "http", "method": "POST", "path": "/", "query_string": b"", "headers": headers}
33+
self.sent: list[Message] = []
34+
self._body = body
35+
self._body_sent = False
36+
37+
async def receive(self) -> Message:
38+
if not self._body_sent:
39+
self._body_sent = True
40+
return {"type": "http.request", "body": self._body, "more_body": False}
41+
raise NotImplementedError
42+
43+
async def send(self, message: Message) -> None:
44+
self.sent.append(message)
45+
46+
2847
@pytest.mark.anyio
2948
async def test_router_unconsumed_request_stream_does_not_block_siblings() -> None:
3049
"""A response whose `sse_writer` is not yet receiving must not park the router (#1764).
@@ -73,35 +92,18 @@ async def test_priming_store_failure_leaves_no_per_request_state() -> None:
7392
event_store=_PrimingFailingStore(),
7493
)
7594

76-
body = b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}'
77-
scope: Scope = {
78-
"type": "http",
79-
"method": "POST",
80-
"path": "/",
81-
"query_string": b"",
82-
"headers": [
95+
post = _AsgiPost(
96+
b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}',
97+
[
8398
(b"accept", b"application/json, text/event-stream"),
8499
(b"content-type", b"application/json"),
85100
(b"mcp-protocol-version", b"2025-11-25"),
86101
],
87-
}
88-
body_sent = False
89-
90-
async def receive() -> Message:
91-
nonlocal body_sent
92-
if not body_sent:
93-
body_sent = True
94-
return {"type": "http.request", "body": body, "more_body": False}
95-
raise NotImplementedError
96-
97-
sent: list[Message] = []
98-
99-
async def asgi_send(message: Message) -> None:
100-
sent.append(message)
102+
)
101103

102104
async with transport.connect() as (read_stream, _write_stream):
103105
async with anyio.create_task_group() as tg:
104-
tg.start_soon(transport.handle_request, scope, receive, asgi_send)
106+
tg.start_soon(transport.handle_request, post.scope, post.receive, post.send)
105107
with anyio.fail_after(5):
106108
forwarded = await read_stream.receive()
107109
assert isinstance(forwarded, Exception)
@@ -110,49 +112,32 @@ async def asgi_send(message: Message) -> None:
110112
assert transport._request_streams == {}
111113
assert transport._sse_stream_writers == {}
112114

113-
assert sent[0]["type"] == "http.response.start"
114-
assert sent[0]["status"] == 500
115-
body = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")
115+
assert post.sent[0]["type"] == "http.response.start"
116+
assert post.sent[0]["status"] == 500
117+
body = b"".join(m.get("body", b"") for m in post.sent if m["type"] == "http.response.body")
116118
assert b"backend unavailable" not in body
117119

118120

119121
@pytest.mark.anyio
120122
async def test_json_post_answers_500_when_session_terminates_mid_request() -> None:
121123
"""A JSON-mode POST whose session is torn down before the handler answers gets a 500, not a stall."""
122124
transport = StreamableHTTPServerTransport(mcp_session_id="sid", is_json_response_enabled=True)
123-
body = b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}'
124-
scope: Scope = {
125-
"type": "http",
126-
"method": "POST",
127-
"path": "/",
128-
"query_string": b"",
129-
"headers": [
125+
post = _AsgiPost(
126+
b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}',
127+
[
130128
(b"accept", b"application/json"),
131129
(b"content-type", b"application/json"),
132130
(b"mcp-session-id", b"sid"),
133131
(b"mcp-protocol-version", b"2025-11-25"),
134132
],
135-
}
136-
body_sent = False
137-
138-
async def receive() -> Message:
139-
nonlocal body_sent
140-
if not body_sent:
141-
body_sent = True
142-
return {"type": "http.request", "body": body, "more_body": False}
143-
raise NotImplementedError
144-
145-
sent: list[Message] = []
146-
147-
async def asgi_send(message: Message) -> None:
148-
sent.append(message)
133+
)
149134

150135
async with transport.connect() as (read_stream, _write_stream):
151136
async with anyio.create_task_group() as tg:
152-
tg.start_soon(transport.handle_request, scope, receive, asgi_send)
137+
tg.start_soon(transport.handle_request, post.scope, post.receive, post.send)
153138
with anyio.fail_after(5):
154139
await read_stream.receive() # the request reached the session; the POST is parked
155140
await transport.terminate()
156141

157-
assert sent[0]["type"] == "http.response.start"
158-
assert sent[0]["status"] == 500
142+
assert post.sent[0]["type"] == "http.response.start"
143+
assert post.sent[0]["status"] == 500

0 commit comments

Comments
 (0)