Skip to content

Commit 4191aa6

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 4191aa6

File tree

4 files changed

+64
-66
lines changed

4 files changed

+64
-66
lines changed

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ This document contains critical information about working with this codebase. Fo
2424
- Coverage: test edge cases and errors
2525
- New features require tests
2626
- Bug fixes require regression tests
27-
- NEVER use `anyio.sleep()` with a fixed duration as a synchronization mechanism. Instead:
27+
- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead:
2828
- Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test
2929
- For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()`
30-
- Wrap waits in `anyio.fail_after(5)` as a timeout guard
30+
- Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts)
31+
- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs
3132

3233
- For commits fixing bugs or adding features based on user reports add:
3334

src/mcp/server/streamable_http.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -778,7 +778,7 @@ async def terminate(self) -> None:
778778
Calling this method multiple times is safe (idempotent).
779779
"""
780780

781-
if self._terminated:
781+
if self._terminated: # pragma: no cover
782782
return
783783

784784
self._terminated = True

src/mcp/server/streamable_http_manager.py

Lines changed: 18 additions & 27 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,12 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
283278
)
284279

285280
if idle_scope.cancelled_caught:
286-
session_id = http_transport.mcp_session_id
287-
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)
281+
assert http_transport.mcp_session_id is not None
282+
logger.info(f"Session {http_transport.mcp_session_id} idle timeout")
283+
self._server_instances.pop(http_transport.mcp_session_id, None)
290284
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-
)
285+
except Exception:
286+
logger.exception(f"Session {http_transport.mcp_session_id} crashed")
296287
finally:
297288
if ( # pragma: no branch
298289
http_transport.mcp_session_id

tests/server/test_streamable_http_manager.py

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -317,10 +317,9 @@ 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+
manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=0.05)
324323

325324
async with manager.run():
326325
sent_messages: list[Message] = []
@@ -338,7 +337,6 @@ async def mock_send(message: Message):
338337
async def mock_receive(): # pragma: no cover
339338
return {"type": "http.request", "body": b"", "more_body": False}
340339

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

344342
session_id = None
@@ -352,35 +350,43 @@ async def mock_receive(): # pragma: no cover
352350
break
353351

354352
assert session_id is not None, "Session ID not found in response headers"
355-
assert session_id in manager._server_instances
356-
357-
# Verify the idle deadline was set correctly
358-
transport = manager._server_instances[session_id]
359-
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
363-
transport.idle_scope.deadline = anyio.current_time()
364-
365-
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)
353+
354+
# Wait for the 50ms idle timeout to fire and cleanup to complete
355+
await anyio.sleep(0.1)
356+
357+
# Verify via public API: old session ID now returns 404
358+
response_messages: list[Message] = []
359+
360+
async def capture_send(message: Message):
361+
response_messages.append(message)
362+
363+
scope_with_session = {
364+
"type": "http",
365+
"method": "POST",
366+
"path": "/mcp",
367+
"headers": [
368+
(b"content-type", b"application/json"),
369+
(b"mcp-session-id", session_id.encode()),
370+
],
371+
}
372+
373+
await manager.handle_request(scope_with_session, mock_receive, capture_send)
374+
375+
response_start = next(
376+
(msg for msg in response_messages if msg["type"] == "http.response.start"),
377+
None,
378+
)
379+
assert response_start is not None
380+
assert response_start["status"] == 404
381+
382+
383+
def test_session_idle_timeout_rejects_non_positive():
384+
with pytest.raises(ValueError, match="positive number"):
385+
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=-1)
386+
with pytest.raises(ValueError, match="positive number"):
387+
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=0)
388+
389+
390+
def test_session_idle_timeout_rejects_stateless():
391+
with pytest.raises(RuntimeError, match="not supported in stateless"):
392+
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True)

0 commit comments

Comments
 (0)