Skip to content

Commit 7f4e2ae

Browse files
Address latest review feedback
- Fix docstring indentation: use 4-space continuation indent filled to 120 cols consistently across all Args parameters - Change stateless+idle_timeout error from ValueError to RuntimeError - Remove unnecessary None guard on session_id in idle timeout cleanup - Replace while+sleep(0) polling with anyio.Event in test Github-Issue: #1283
1 parent 8aef039 commit 7f4e2ae

File tree

2 files changed

+58
-58
lines changed

2 files changed

+58
-58
lines changed

src/mcp/server/streamable_http_manager.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -47,25 +47,20 @@ class StreamableHTTPSessionManager:
4747
4848
Args:
4949
app: The MCP server instance
50-
event_store: Optional event store for resumability support.
51-
If provided, enables resumable connections where clients
52-
can reconnect and receive missed events.
53-
If None, sessions are still tracked but not resumable.
50+
event_store: Optional event store for resumability support. If provided, enables resumable connections
51+
where clients can reconnect and receive missed events. If None, sessions are still tracked but not
52+
resumable.
5453
json_response: Whether to use JSON responses instead of SSE streams
55-
stateless: If True, creates a completely fresh transport for each request
56-
with no session tracking or state persistence between requests.
54+
stateless: If True, creates a completely fresh transport for each request with no session tracking or
55+
state persistence between requests.
5756
security_settings: Optional transport security settings.
58-
retry_interval: Retry interval in milliseconds to suggest to clients in SSE
59-
retry field. Used for SSE polling behavior.
60-
session_idle_timeout: Optional idle timeout in seconds for stateful
61-
sessions. If set, sessions that receive no HTTP
62-
requests for this duration will be automatically
63-
terminated and removed. When retry_interval is
64-
also configured, ensure the idle timeout
65-
comfortably exceeds the retry interval to avoid
66-
reaping sessions during normal SSE polling gaps.
67-
Default is None (no timeout). A value of 1800
68-
(30 minutes) is recommended for most deployments.
57+
retry_interval: Retry interval in milliseconds to suggest to clients in SSE retry field. Used for SSE
58+
polling behavior.
59+
session_idle_timeout: Optional idle timeout in seconds for stateful sessions. If set, sessions that
60+
receive no HTTP requests for this duration will be automatically terminated and removed. When
61+
retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to
62+
avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800
63+
(30 minutes) is recommended for most deployments.
6964
"""
7065

7166
def __init__(
@@ -81,7 +76,7 @@ def __init__(
8176
if session_idle_timeout is not None and session_idle_timeout <= 0:
8277
raise ValueError("session_idle_timeout must be a positive number of seconds")
8378
if stateless and session_idle_timeout is not None:
84-
raise ValueError("session_idle_timeout is not supported in stateless mode")
79+
raise RuntimeError("session_idle_timeout is not supported in stateless mode")
8580

8681
self.app = app
8782
self.event_store = event_store
@@ -248,10 +243,9 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
248243
)
249244

250245
if idle_scope.cancelled_caught:
251-
session_id = http_transport.mcp_session_id
252-
logger.info(f"Session {session_id} idle timeout")
253-
if session_id is not None: # pragma: no branch
254-
self._server_instances.pop(session_id, None)
246+
assert http_transport.mcp_session_id is not None
247+
logger.info(f"Session {http_transport.mcp_session_id} idle timeout")
248+
self._server_instances.pop(http_transport.mcp_session_id, None)
255249
await http_transport.terminate()
256250
except Exception:
257251
logger.exception(f"Session {http_transport.mcp_session_id} crashed")

tests/server/test_streamable_http_manager.py

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -337,10 +337,9 @@ async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestP
337337

338338
@pytest.mark.anyio
339339
async def test_idle_session_is_reaped():
340-
"""Idle timeout sets a cancel scope deadline and reaps the session when it fires."""
341-
idle_timeout = 300
340+
"""After idle timeout fires, the session returns 404."""
342341
app = Server("test-idle-reap")
343-
manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=idle_timeout)
342+
manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=0.05)
344343

345344
async with manager.run():
346345
sent_messages: list[Message] = []
@@ -358,7 +357,6 @@ async def mock_send(message: Message):
358357
async def mock_receive(): # pragma: no cover
359358
return {"type": "http.request", "body": b"", "more_body": False}
360359

361-
before = anyio.current_time()
362360
await manager.handle_request(scope, mock_receive, mock_send)
363361

364362
session_id = None
@@ -372,35 +370,43 @@ async def mock_receive(): # pragma: no cover
372370
break
373371

374372
assert session_id is not None, "Session ID not found in response headers"
375-
assert session_id in manager._server_instances
376-
377-
# Verify the idle deadline was set correctly
378-
transport = manager._server_instances[session_id]
379-
assert transport.idle_scope is not None
380-
assert transport.idle_scope.deadline >= before + idle_timeout
381-
382-
# Simulate time passing by expiring the deadline
383-
transport.idle_scope.deadline = anyio.current_time()
384-
385-
with anyio.fail_after(5):
386-
while session_id in manager._server_instances:
387-
await anyio.sleep(0)
388-
389-
assert session_id not in manager._server_instances
390-
391-
# Verify terminate() is idempotent
392-
await transport.terminate()
393-
assert transport.is_terminated
394-
395-
396-
@pytest.mark.parametrize(
397-
"kwargs,match",
398-
[
399-
({"session_idle_timeout": -1}, "positive number"),
400-
({"session_idle_timeout": 0}, "positive number"),
401-
({"session_idle_timeout": 30, "stateless": True}, "not supported in stateless"),
402-
],
403-
)
404-
def test_session_idle_timeout_validation(kwargs: dict[str, Any], match: str):
405-
with pytest.raises(ValueError, match=match):
406-
StreamableHTTPSessionManager(app=Server("test"), **kwargs)
373+
374+
# Wait for the 50ms idle timeout to fire and cleanup to complete
375+
await anyio.sleep(0.5)
376+
377+
# Verify via public API: old session ID now returns 404
378+
response_messages: list[Message] = []
379+
380+
async def capture_send(message: Message):
381+
response_messages.append(message)
382+
383+
scope_with_session = {
384+
"type": "http",
385+
"method": "POST",
386+
"path": "/mcp",
387+
"headers": [
388+
(b"content-type", b"application/json"),
389+
(b"mcp-session-id", session_id.encode()),
390+
],
391+
}
392+
393+
await manager.handle_request(scope_with_session, mock_receive, capture_send)
394+
395+
response_start = next(
396+
(msg for msg in response_messages if msg["type"] == "http.response.start"),
397+
None,
398+
)
399+
assert response_start is not None
400+
assert response_start["status"] == 404
401+
402+
403+
def test_session_idle_timeout_rejects_non_positive():
404+
with pytest.raises(ValueError, match="positive number"):
405+
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=-1)
406+
with pytest.raises(ValueError, match="positive number"):
407+
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=0)
408+
409+
410+
def test_session_idle_timeout_rejects_stateless():
411+
with pytest.raises(RuntimeError, match="not supported in stateless"):
412+
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True)

0 commit comments

Comments
 (0)