Skip to content

Commit 3af50b7

Browse files
Merge branch 'main' into fix-1665-empty-uri-metadata
2 parents e9a78ec + 92140e5 commit 3af50b7

File tree

4 files changed

+129
-15
lines changed

4 files changed

+129
-15
lines changed

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ This document contains critical information about working with this codebase. Fo
3131
- IMPORTANT: Before pushing, verify 100% branch coverage on changed files by running
3232
`uv run --frozen pytest -x` (coverage is configured in `pyproject.toml` with `fail_under = 100`
3333
and `branch = true`). If any branch is uncovered, add a test for it before pushing.
34+
- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead:
35+
- Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test
36+
- For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()`
37+
- Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts)
38+
- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs
3439

3540
Test files mirror the source tree: `src/mcp/client/streamable_http.py``tests/client/test_streamable_http.py`
3641
Add tests to the existing file for that module.

src/mcp/server/streamable_http.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ def __init__(
169169
] = {}
170170
self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[dict[str, str]]] = {}
171171
self._terminated = False
172+
# Idle timeout cancel scope; managed by the session manager.
173+
self.idle_scope: anyio.CancelScope | None = None
172174

173175
@property
174176
def is_terminated(self) -> bool:

src/mcp/server/streamable_http_manager.py

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,28 @@ class StreamableHTTPSessionManager:
3939
2. Resumability via an optional event store
4040
3. Connection management and lifecycle
4141
4. Request handling and transport setup
42+
5. Idle session cleanup via optional timeout
4243
4344
Important: Only one StreamableHTTPSessionManager instance should be created
4445
per application. The instance cannot be reused after its run() context has
4546
completed. If you need to restart the manager, create a new instance.
4647
4748
Args:
4849
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.
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.
5353
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.
54+
stateless: If True, creates a completely fresh transport for each request with no session tracking or
55+
state persistence between requests.
5656
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.
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.
5964
"""
6065

6166
def __init__(
@@ -66,13 +71,20 @@ def __init__(
6671
stateless: bool = False,
6772
security_settings: TransportSecuritySettings | None = None,
6873
retry_interval: int | None = None,
74+
session_idle_timeout: float | None = None,
6975
):
76+
if session_idle_timeout is not None and session_idle_timeout <= 0:
77+
raise ValueError("session_idle_timeout must be a positive number of seconds")
78+
if stateless and session_idle_timeout is not None:
79+
raise RuntimeError("session_idle_timeout is not supported in stateless mode")
80+
7081
self.app = app
7182
self.event_store = event_store
7283
self.json_response = json_response
7384
self.stateless = stateless
7485
self.security_settings = security_settings
7586
self.retry_interval = retry_interval
87+
self.session_idle_timeout = session_idle_timeout
7688

7789
# Session tracking (only used if not stateless)
7890
self._session_creation_lock = anyio.Lock()
@@ -184,6 +196,9 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
184196
if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances:
185197
transport = self._server_instances[request_mcp_session_id]
186198
logger.debug("Session already exists, handling request directly")
199+
# Push back idle deadline on activity
200+
if transport.idle_scope is not None and self.session_idle_timeout is not None:
201+
transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout # pragma: no cover
187202
await transport.handle_request(scope, receive, send)
188203
return
189204

@@ -210,16 +225,31 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
210225
read_stream, write_stream = streams
211226
task_status.started()
212227
try:
213-
await self.app.run(
214-
read_stream,
215-
write_stream,
216-
self.app.create_initialization_options(),
217-
stateless=False, # Stateful mode
218-
)
228+
# Use a cancel scope for idle timeout — when the
229+
# deadline passes the scope cancels app.run() and
230+
# execution continues after the ``with`` block.
231+
# Incoming requests push the deadline forward.
232+
idle_scope = anyio.CancelScope()
233+
if self.session_idle_timeout is not None:
234+
idle_scope.deadline = anyio.current_time() + self.session_idle_timeout
235+
http_transport.idle_scope = idle_scope
236+
237+
with idle_scope:
238+
await self.app.run(
239+
read_stream,
240+
write_stream,
241+
self.app.create_initialization_options(),
242+
stateless=False,
243+
)
244+
245+
if idle_scope.cancelled_caught:
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)
249+
await http_transport.terminate()
219250
except Exception:
220251
logger.exception(f"Session {http_transport.mcp_session_id} crashed")
221252
finally:
222-
# Only remove from instances if not terminated
223253
if ( # pragma: no branch
224254
http_transport.mcp_session_id
225255
and http_transport.mcp_session_id in self._server_instances

tests/server/test_streamable_http_manager.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,80 @@ async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestP
333333
Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client)) as client,
334334
):
335335
await client.list_tools()
336+
337+
338+
@pytest.mark.anyio
339+
async def test_idle_session_is_reaped():
340+
"""After idle timeout fires, the session returns 404."""
341+
app = Server("test-idle-reap")
342+
manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=0.05)
343+
344+
async with manager.run():
345+
sent_messages: list[Message] = []
346+
347+
async def mock_send(message: Message):
348+
sent_messages.append(message)
349+
350+
scope = {
351+
"type": "http",
352+
"method": "POST",
353+
"path": "/mcp",
354+
"headers": [(b"content-type", b"application/json")],
355+
}
356+
357+
async def mock_receive(): # pragma: no cover
358+
return {"type": "http.request", "body": b"", "more_body": False}
359+
360+
await manager.handle_request(scope, mock_receive, mock_send)
361+
362+
session_id = None
363+
for msg in sent_messages: # pragma: no branch
364+
if msg["type"] == "http.response.start": # pragma: no branch
365+
for header_name, header_value in msg.get("headers", []): # pragma: no branch
366+
if header_name.decode().lower() == MCP_SESSION_ID_HEADER.lower():
367+
session_id = header_value.decode()
368+
break
369+
if session_id: # pragma: no branch
370+
break
371+
372+
assert session_id is not None, "Session ID not found in response headers"
373+
374+
# Wait for the 50ms idle timeout to fire and cleanup to complete
375+
await anyio.sleep(0.1)
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)