Skip to content

Commit 5d59eeb

Browse files
committed
fix: ServerSession.send_request fails fast without a back channel; narrow metadata
With no related request to ride on and no standalone channel (stateless HTTP), a server-to-client request could never receive its response; raise NoBackChannelError instead of parking forever, matching Connection.send_raw_request. Narrow the metadata parameter to ServerMessageMetadata | None: only related_request_id is honored, so accepting the full MessageMetadata union silently discarded ClientMessageMetadata.
1 parent c599b9a commit 5d59eeb

2 files changed

Lines changed: 38 additions & 7 deletions

File tree

src/mcp/server/session.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
from mcp.server.connection import Connection
1818
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
1919
from mcp.shared.dispatcher import CallOptions, ProgressFnT
20-
from mcp.shared.exceptions import StatelessModeNotSupported
20+
from mcp.shared.exceptions import NoBackChannelError, StatelessModeNotSupported
2121
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
22-
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
22+
from mcp.shared.message import ServerMessageMetadata
2323

2424
__all__ = ["ServerSession"]
2525

@@ -55,22 +55,31 @@ async def send_request(
5555
request: types.ServerRequest,
5656
result_type: type[ResultT],
5757
request_read_timeout_seconds: float | None = None,
58-
metadata: MessageMetadata = None,
58+
metadata: ServerMessageMetadata | None = None,
5959
progress_callback: ProgressFnT | None = None,
6060
) -> ResultT:
6161
"""Send a typed server-to-client request and validate the result.
6262
6363
`metadata.related_request_id` (when supplied) routes the outgoing
6464
message onto the originating request's response stream over
65-
streamable HTTP.
65+
streamable HTTP; it is the only metadata field honored here.
66+
67+
Raises:
68+
NoBackChannelError: If there is no related request to ride on and
69+
the connection has no standalone channel (stateless HTTP), so
70+
a response could never arrive.
6671
"""
6772
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
6873
opts: CallOptions = {}
6974
if request_read_timeout_seconds is not None:
7075
opts["timeout"] = request_read_timeout_seconds
7176
if progress_callback is not None:
7277
opts["on_progress"] = progress_callback
73-
related = metadata.related_request_id if isinstance(metadata, ServerMessageMetadata) else None
78+
related = metadata.related_request_id if metadata is not None else None
79+
if related is None and not self._connection.has_standalone_channel:
80+
# Fail fast instead of parking forever on a response that cannot
81+
# arrive; matches `Connection.send_raw_request`.
82+
raise NoBackChannelError(data["method"])
7483
result = await self._dispatcher.send_raw_request(
7584
data["method"], data.get("params"), opts or None, _related_request_id=related
7685
)

tests/server/test_session.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from mcp.server.connection import Connection
1515
from mcp.server.session import ServerSession
1616
from mcp.shared.dispatcher import CallOptions
17+
from mcp.shared.exceptions import NoBackChannelError
1718
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
1819
from mcp.shared.message import ServerMessageMetadata
1920
from mcp.types import (
@@ -48,8 +49,13 @@ async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
4849
raise NotImplementedError
4950

5051

51-
def _make_session(dispatcher: StubDispatcher, *, capabilities: ClientCapabilities | None = None) -> ServerSession:
52-
conn = Connection(dispatcher, has_standalone_channel=True)
52+
def _make_session(
53+
dispatcher: StubDispatcher,
54+
*,
55+
capabilities: ClientCapabilities | None = None,
56+
has_standalone_channel: bool = True,
57+
) -> ServerSession:
58+
conn = Connection(dispatcher, has_standalone_channel=has_standalone_channel)
5359
if capabilities is not None:
5460
conn.client_params = InitializeRequestParams(
5561
protocol_version=LATEST_PROTOCOL_VERSION,
@@ -93,6 +99,22 @@ async def test_send_request_omits_call_options_when_none_given():
9399
assert related is None
94100

95101

102+
@pytest.mark.anyio
103+
async def test_send_request_without_back_channel_or_related_id_fails_fast():
104+
"""No standalone channel and no related request to ride on: raise instead
105+
of parking forever on a response that cannot arrive."""
106+
dispatcher = StubDispatcher(result={})
107+
session = _make_session(dispatcher, has_standalone_channel=False)
108+
with pytest.raises(NoBackChannelError):
109+
await session.send_request(types.PingRequest(), types.EmptyResult)
110+
assert dispatcher.requests == []
111+
# With a related request id the message rides that request's stream.
112+
await session.send_request(
113+
types.PingRequest(), types.EmptyResult, metadata=ServerMessageMetadata(related_request_id=3)
114+
)
115+
assert dispatcher.requests[0][3] == 3
116+
117+
96118
@pytest.mark.anyio
97119
async def test_send_request_validates_result_alias_only():
98120
"""Peer results validate alias-only; a snake_case key from the wire is

0 commit comments

Comments
 (0)