Skip to content

Commit fd452aa

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 - Use logger.exception() instead of logger.error() with exc_info - 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 d5686df commit fd452aa

File tree

2 files changed

+77
-55
lines changed

2 files changed

+77
-55
lines changed

src/mcp/server/streamable_http_manager.py

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

7065
def __init__(
@@ -80,7 +75,7 @@ def __init__(
8075
if session_idle_timeout is not None and session_idle_timeout <= 0:
8176
raise ValueError("session_idle_timeout must be a positive number of seconds")
8277
if stateless and session_idle_timeout is not None:
83-
raise ValueError("session_idle_timeout is not supported in stateless mode")
78+
raise RuntimeError("session_idle_timeout is not supported in stateless mode")
8479

8580
self.app = app
8681
self.event_store = event_store
@@ -283,16 +278,13 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
283278
)
284279

285280
if idle_scope.cancelled_caught:
281+
assert http_transport.mcp_session_id is not None
286282
session_id = http_transport.mcp_session_id
287283
logger.info(f"Session {session_id} idle timeout")
288-
if session_id is not None: # pragma: no branch
289-
self._server_instances.pop(session_id, None)
284+
self._server_instances.pop(session_id, None)
290285
await http_transport.terminate()
291-
except Exception as e:
292-
logger.error(
293-
f"Session {http_transport.mcp_session_id} crashed: {e}",
294-
exc_info=True,
295-
)
286+
except Exception:
287+
logger.exception(f"Session {http_transport.mcp_session_id} crashed")
296288
finally:
297289
if ( # pragma: no branch
298290
http_transport.mcp_session_id

tests/server/test_streamable_http_manager.py

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -317,12 +317,24 @@ async def mock_receive():
317317

318318
@pytest.mark.anyio
319319
async def test_idle_session_is_reaped():
320-
"""Idle timeout sets a cancel scope deadline and reaps the session when it fires."""
321-
idle_timeout = 300
320+
"""After idle timeout fires, the session returns 404."""
322321
app = Server("test-idle-reap")
323-
manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=idle_timeout)
322+
323+
run_finished = anyio.Event()
324+
original_run = app.run
325+
326+
async def tracked_run(*args: Any, **kwargs: Any) -> None:
327+
try:
328+
await original_run(*args, **kwargs)
329+
finally:
330+
run_finished.set()
331+
332+
app.run = tracked_run # type: ignore[assignment]
333+
334+
manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=300)
324335

325336
async with manager.run():
337+
# Create a session
326338
sent_messages: list[Message] = []
327339

328340
async def mock_send(message: Message):
@@ -338,7 +350,6 @@ async def mock_send(message: Message):
338350
async def mock_receive(): # pragma: no cover
339351
return {"type": "http.request", "body": b"", "more_body": False}
340352

341-
before = anyio.current_time()
342353
await manager.handle_request(scope, mock_receive, mock_send)
343354

344355
session_id = None
@@ -352,35 +363,54 @@ async def mock_receive(): # pragma: no cover
352363
break
353364

354365
assert session_id is not None, "Session ID not found in response headers"
355-
assert session_id in manager._server_instances
356366

357-
# Verify the idle deadline was set correctly
367+
# Force the idle deadline to expire
358368
transport = manager._server_instances[session_id]
359369
assert transport.idle_scope is not None
360-
assert transport.idle_scope.deadline >= before + idle_timeout
361-
362-
# Simulate time passing by expiring the deadline
363370
transport.idle_scope.deadline = anyio.current_time()
364371

372+
# Wait for app.run to exit via the cancel scope, then one checkpoint for cleanup
365373
with anyio.fail_after(5):
366-
while session_id in manager._server_instances:
367-
await anyio.sleep(0)
368-
369-
assert session_id not in manager._server_instances
370-
371-
# Verify terminate() is idempotent
372-
await transport.terminate()
373-
assert transport.is_terminated
374-
375-
376-
@pytest.mark.parametrize(
377-
"kwargs,match",
378-
[
379-
({"session_idle_timeout": -1}, "positive number"),
380-
({"session_idle_timeout": 0}, "positive number"),
381-
({"session_idle_timeout": 30, "stateless": True}, "not supported in stateless"),
382-
],
383-
)
384-
def test_session_idle_timeout_validation(kwargs: dict[str, Any], match: str):
385-
with pytest.raises(ValueError, match=match):
386-
StreamableHTTPSessionManager(app=Server("test"), **kwargs)
374+
await run_finished.wait()
375+
await anyio.sleep(0)
376+
377+
# Verify session is gone via public API: request with old session ID returns 404
378+
response_messages: list[Message] = []
379+
response_body = b""
380+
381+
async def capture_send(message: Message):
382+
nonlocal response_body
383+
response_messages.append(message)
384+
if message["type"] == "http.response.body":
385+
response_body += message.get("body", b"")
386+
387+
scope_with_session = {
388+
"type": "http",
389+
"method": "POST",
390+
"path": "/mcp",
391+
"headers": [
392+
(b"content-type", b"application/json"),
393+
(b"mcp-session-id", session_id.encode()),
394+
],
395+
}
396+
397+
await manager.handle_request(scope_with_session, mock_receive, capture_send)
398+
399+
response_start = next(
400+
(msg for msg in response_messages if msg["type"] == "http.response.start"),
401+
None,
402+
)
403+
assert response_start is not None
404+
assert response_start["status"] == 404
405+
406+
407+
def test_session_idle_timeout_rejects_non_positive():
408+
with pytest.raises(ValueError, match="positive number"):
409+
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=-1)
410+
with pytest.raises(ValueError, match="positive number"):
411+
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=0)
412+
413+
414+
def test_session_idle_timeout_rejects_stateless():
415+
with pytest.raises(RuntimeError, match="not supported in stateless"):
416+
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True)

0 commit comments

Comments
 (0)