Skip to content

Commit bb6cb02

Browse files
authored
fix: raise clear error for server-to-client requests in stateless mode (#1827)
1 parent d52937b commit bb6cb02

File tree

2 files changed

+295
-0
lines changed

2 files changed

+295
-0
lines changed

src/mcp/server/session.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def __init__(
9393
stateless: bool = False,
9494
) -> None:
9595
super().__init__(read_stream, write_stream, types.ClientRequest, types.ClientNotification)
96+
self._stateless = stateless
9697
self._initialization_state = (
9798
InitializationState.Initialized if stateless else InitializationState.NotInitialized
9899
)
@@ -156,6 +157,26 @@ def check_client_capability(self, capability: types.ClientCapabilities) -> bool:
156157

157158
return True
158159

160+
def _require_stateful_mode(self, feature_name: str) -> None:
161+
"""Raise an error if trying to use a feature that requires stateful mode.
162+
163+
Server-to-client requests (sampling, elicitation, list_roots) are not
164+
supported in stateless HTTP mode because there is no persistent connection
165+
for bidirectional communication.
166+
167+
Args:
168+
feature_name: Name of the feature being used (for error message)
169+
170+
Raises:
171+
RuntimeError: If the session is in stateless mode
172+
"""
173+
if self._stateless:
174+
raise RuntimeError(
175+
f"Cannot use {feature_name} in stateless HTTP mode. "
176+
"Stateless mode does not support server-to-client requests. "
177+
"Use stateful mode (stateless_http=False) to enable this feature."
178+
)
179+
159180
async def _receive_loop(self) -> None:
160181
async with self._incoming_message_stream_writer:
161182
await super()._receive_loop()
@@ -311,7 +332,9 @@ async def create_message(
311332
Raises:
312333
McpError: If tools are provided but client doesn't support them.
313334
ValueError: If tool_use or tool_result message structure is invalid.
335+
RuntimeError: If called in stateless HTTP mode.
314336
"""
337+
self._require_stateful_mode("sampling")
315338
client_caps = self._client_params.capabilities if self._client_params else None
316339
validate_sampling_tools(client_caps, tools, tool_choice)
317340
validate_tool_use_result_messages(messages)
@@ -349,6 +372,7 @@ async def create_message(
349372

350373
async def list_roots(self) -> types.ListRootsResult:
351374
"""Send a roots/list request."""
375+
self._require_stateful_mode("list_roots")
352376
return await self.send_request(
353377
types.ServerRequest(types.ListRootsRequest()),
354378
types.ListRootsResult,
@@ -391,7 +415,11 @@ async def elicit_form(
391415
392416
Returns:
393417
The client's response with form data
418+
419+
Raises:
420+
RuntimeError: If called in stateless HTTP mode.
394421
"""
422+
self._require_stateful_mode("elicitation")
395423
return await self.send_request(
396424
types.ServerRequest(
397425
types.ElicitRequest(
@@ -425,7 +453,11 @@ async def elicit_url(
425453
426454
Returns:
427455
The client's response indicating acceptance, decline, or cancellation
456+
457+
Raises:
458+
RuntimeError: If called in stateless HTTP mode.
428459
"""
460+
self._require_stateful_mode("elicitation")
429461
return await self.send_request(
430462
types.ServerRequest(
431463
types.ElicitRequest(
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
"""Tests for stateless HTTP mode limitations.
2+
3+
Stateless HTTP mode does not support server-to-client requests because there
4+
is no persistent connection for bidirectional communication. These tests verify
5+
that appropriate errors are raised when attempting to use unsupported features.
6+
7+
See: https://github.com/modelcontextprotocol/python-sdk/issues/1097
8+
"""
9+
10+
import anyio
11+
import pytest
12+
13+
import mcp.types as types
14+
from mcp.server.models import InitializationOptions
15+
from mcp.server.session import ServerSession
16+
from mcp.shared.message import SessionMessage
17+
from mcp.types import ServerCapabilities
18+
19+
20+
def create_test_streams():
21+
"""Create memory streams for testing."""
22+
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
23+
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1)
24+
return (
25+
server_to_client_send,
26+
server_to_client_receive,
27+
client_to_server_send,
28+
client_to_server_receive,
29+
)
30+
31+
32+
def create_init_options():
33+
"""Create default initialization options for testing."""
34+
return InitializationOptions(
35+
server_name="test",
36+
server_version="0.1.0",
37+
capabilities=ServerCapabilities(),
38+
)
39+
40+
41+
@pytest.mark.anyio
42+
async def test_list_roots_fails_in_stateless_mode():
43+
"""Test that list_roots raises RuntimeError in stateless mode."""
44+
(
45+
server_to_client_send,
46+
server_to_client_receive,
47+
client_to_server_send,
48+
client_to_server_receive,
49+
) = create_test_streams()
50+
51+
async with (
52+
client_to_server_send,
53+
client_to_server_receive,
54+
server_to_client_send,
55+
server_to_client_receive,
56+
):
57+
async with ServerSession(
58+
client_to_server_receive,
59+
server_to_client_send,
60+
create_init_options(),
61+
stateless=True,
62+
) as session:
63+
with pytest.raises(RuntimeError) as exc_info:
64+
await session.list_roots()
65+
66+
assert "stateless HTTP mode" in str(exc_info.value)
67+
assert "list_roots" in str(exc_info.value)
68+
69+
70+
@pytest.mark.anyio
71+
async def test_create_message_fails_in_stateless_mode():
72+
"""Test that create_message raises RuntimeError in stateless mode."""
73+
(
74+
server_to_client_send,
75+
server_to_client_receive,
76+
client_to_server_send,
77+
client_to_server_receive,
78+
) = create_test_streams()
79+
80+
async with (
81+
client_to_server_send,
82+
client_to_server_receive,
83+
server_to_client_send,
84+
server_to_client_receive,
85+
):
86+
async with ServerSession(
87+
client_to_server_receive,
88+
server_to_client_send,
89+
create_init_options(),
90+
stateless=True,
91+
) as session:
92+
with pytest.raises(RuntimeError) as exc_info:
93+
await session.create_message(
94+
messages=[
95+
types.SamplingMessage(
96+
role="user",
97+
content=types.TextContent(type="text", text="hello"),
98+
)
99+
],
100+
max_tokens=100,
101+
)
102+
103+
assert "stateless HTTP mode" in str(exc_info.value)
104+
assert "sampling" in str(exc_info.value)
105+
106+
107+
@pytest.mark.anyio
108+
async def test_elicit_form_fails_in_stateless_mode():
109+
"""Test that elicit_form raises RuntimeError in stateless mode."""
110+
(
111+
server_to_client_send,
112+
server_to_client_receive,
113+
client_to_server_send,
114+
client_to_server_receive,
115+
) = create_test_streams()
116+
117+
async with (
118+
client_to_server_send,
119+
client_to_server_receive,
120+
server_to_client_send,
121+
server_to_client_receive,
122+
):
123+
async with ServerSession(
124+
client_to_server_receive,
125+
server_to_client_send,
126+
create_init_options(),
127+
stateless=True,
128+
) as session:
129+
with pytest.raises(RuntimeError) as exc_info:
130+
await session.elicit_form(
131+
message="Please provide input",
132+
requestedSchema={"type": "object", "properties": {}},
133+
)
134+
135+
assert "stateless HTTP mode" in str(exc_info.value)
136+
assert "elicitation" in str(exc_info.value)
137+
138+
139+
@pytest.mark.anyio
140+
async def test_elicit_url_fails_in_stateless_mode():
141+
"""Test that elicit_url raises RuntimeError in stateless mode."""
142+
(
143+
server_to_client_send,
144+
server_to_client_receive,
145+
client_to_server_send,
146+
client_to_server_receive,
147+
) = create_test_streams()
148+
149+
async with (
150+
client_to_server_send,
151+
client_to_server_receive,
152+
server_to_client_send,
153+
server_to_client_receive,
154+
):
155+
async with ServerSession(
156+
client_to_server_receive,
157+
server_to_client_send,
158+
create_init_options(),
159+
stateless=True,
160+
) as session:
161+
with pytest.raises(RuntimeError) as exc_info:
162+
await session.elicit_url(
163+
message="Please authenticate",
164+
url="https://example.com/auth",
165+
elicitation_id="test-123",
166+
)
167+
168+
assert "stateless HTTP mode" in str(exc_info.value)
169+
assert "elicitation" in str(exc_info.value)
170+
171+
172+
@pytest.mark.anyio
173+
async def test_elicit_deprecated_fails_in_stateless_mode():
174+
"""Test that the deprecated elicit method also fails in stateless mode."""
175+
(
176+
server_to_client_send,
177+
server_to_client_receive,
178+
client_to_server_send,
179+
client_to_server_receive,
180+
) = create_test_streams()
181+
182+
async with (
183+
client_to_server_send,
184+
client_to_server_receive,
185+
server_to_client_send,
186+
server_to_client_receive,
187+
):
188+
async with ServerSession(
189+
client_to_server_receive,
190+
server_to_client_send,
191+
create_init_options(),
192+
stateless=True,
193+
) as session:
194+
with pytest.raises(RuntimeError) as exc_info:
195+
await session.elicit(
196+
message="Please provide input",
197+
requestedSchema={"type": "object", "properties": {}},
198+
)
199+
200+
assert "stateless HTTP mode" in str(exc_info.value)
201+
assert "elicitation" in str(exc_info.value)
202+
203+
204+
@pytest.mark.anyio
205+
async def test_require_stateful_mode_does_not_raise_in_stateful_mode():
206+
"""Test that _require_stateful_mode does not raise in stateful mode."""
207+
(
208+
server_to_client_send,
209+
server_to_client_receive,
210+
client_to_server_send,
211+
client_to_server_receive,
212+
) = create_test_streams()
213+
214+
async with (
215+
client_to_server_send,
216+
client_to_server_receive,
217+
server_to_client_send,
218+
server_to_client_receive,
219+
):
220+
async with ServerSession(
221+
client_to_server_receive,
222+
server_to_client_send,
223+
create_init_options(),
224+
stateless=False, # Stateful mode
225+
) as session:
226+
# These should not raise - the check passes in stateful mode
227+
session._require_stateful_mode("list_roots")
228+
session._require_stateful_mode("sampling")
229+
session._require_stateful_mode("elicitation")
230+
231+
232+
@pytest.mark.anyio
233+
async def test_stateless_error_message_is_actionable():
234+
"""Test that the error message provides actionable guidance."""
235+
(
236+
server_to_client_send,
237+
server_to_client_receive,
238+
client_to_server_send,
239+
client_to_server_receive,
240+
) = create_test_streams()
241+
242+
async with (
243+
client_to_server_send,
244+
client_to_server_receive,
245+
server_to_client_send,
246+
server_to_client_receive,
247+
):
248+
async with ServerSession(
249+
client_to_server_receive,
250+
server_to_client_send,
251+
create_init_options(),
252+
stateless=True,
253+
) as session:
254+
with pytest.raises(RuntimeError) as exc_info:
255+
await session.list_roots()
256+
257+
error_message = str(exc_info.value)
258+
# Should mention it's stateless mode
259+
assert "stateless HTTP mode" in error_message
260+
# Should explain why it doesn't work
261+
assert "server-to-client requests" in error_message
262+
# Should tell user how to fix it
263+
assert "stateless_http=False" in error_message

0 commit comments

Comments
 (0)