Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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
  • Loading branch information
felixweinberger committed Feb 16, 2026
commit 2721c9ba6bb59e4e60cf55cbace10400372c140e
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@ This document contains critical information about working with this codebase. Fo
- Bug fixes require regression tests
- IMPORTANT: The `tests/client/test_client.py` is the most well designed test file. Follow its patterns.
- IMPORTANT: Be minimal, and focus on E2E tests: Use the `mcp.client.Client` whenever possible.
- NEVER use `anyio.sleep()` with a fixed duration as a synchronization mechanism. Instead:
- IMPORTANT: Before pushing, verify 100% branch coverage on changed files by running
`uv run --frozen pytest -x` (coverage is configured in `pyproject.toml` with `fail_under = 100`
and `branch = true`). If any branch is uncovered, add a test for it before pushing.
- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead:
- Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test
- For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()`
- Wrap waits in `anyio.fail_after(5)` as a timeout guard
- Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts)
- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs

Test files mirror the source tree: `src/mcp/client/streamable_http.py` → `tests/client/test_streamable_http.py`
Add tests to the existing file for that module.
Expand Down
38 changes: 16 additions & 22 deletions src/mcp/server/streamable_http_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,25 +47,20 @@ class StreamableHTTPSessionManager:

Args:
app: The MCP server instance
event_store: Optional event store for resumability support.
If provided, enables resumable connections where clients
can reconnect and receive missed events.
If None, sessions are still tracked but not resumable.
event_store: Optional event store for resumability support. If provided, enables resumable connections
where clients can reconnect and receive missed events. If None, sessions are still tracked but not
resumable.
json_response: Whether to use JSON responses instead of SSE streams
stateless: If True, creates a completely fresh transport for each request
with no session tracking or state persistence between requests.
stateless: If True, creates a completely fresh transport for each request with no session tracking or
state persistence between requests.
security_settings: Optional transport security settings.
retry_interval: Retry interval in milliseconds to suggest to clients in SSE
retry field. Used for SSE polling behavior.
session_idle_timeout: Optional idle timeout in seconds for stateful
sessions. If set, sessions that receive no HTTP
requests for this duration will be automatically
terminated and removed. When retry_interval is
also configured, ensure the idle timeout
comfortably exceeds the retry interval to avoid
reaping sessions during normal SSE polling gaps.
Default is None (no timeout). A value of 1800
(30 minutes) is recommended for most deployments.
retry_interval: Retry interval in milliseconds to suggest to clients in SSE retry field. Used for SSE
polling behavior.
session_idle_timeout: Optional idle timeout in seconds for stateful sessions. If set, sessions that
receive no HTTP requests for this duration will be automatically terminated and removed. When
retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to
avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800
(30 minutes) is recommended for most deployments.
"""

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

self.app = app
self.event_store = event_store
Expand Down Expand Up @@ -248,10 +243,9 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
)

if idle_scope.cancelled_caught:
session_id = http_transport.mcp_session_id
logger.info(f"Session {session_id} idle timeout")
if session_id is not None: # pragma: no branch
self._server_instances.pop(session_id, None)
assert http_transport.mcp_session_id is not None
logger.info(f"Session {http_transport.mcp_session_id} idle timeout")
self._server_instances.pop(http_transport.mcp_session_id, None)
await http_transport.terminate()
except Exception:
logger.exception(f"Session {http_transport.mcp_session_id} crashed")
Expand Down
78 changes: 42 additions & 36 deletions tests/server/test_streamable_http_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,10 +337,9 @@ async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestP

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

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

before = anyio.current_time()
await manager.handle_request(scope, mock_receive, mock_send)

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

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

# Verify the idle deadline was set correctly
transport = manager._server_instances[session_id]
assert transport.idle_scope is not None
assert transport.idle_scope.deadline >= before + idle_timeout

# Simulate time passing by expiring the deadline
transport.idle_scope.deadline = anyio.current_time()

with anyio.fail_after(5):
while session_id in manager._server_instances:
await anyio.sleep(0)

assert session_id not in manager._server_instances

# Verify terminate() is idempotent
await transport.terminate()
assert transport.is_terminated


@pytest.mark.parametrize(
"kwargs,match",
[
({"session_idle_timeout": -1}, "positive number"),
({"session_idle_timeout": 0}, "positive number"),
({"session_idle_timeout": 30, "stateless": True}, "not supported in stateless"),
],
)
def test_session_idle_timeout_validation(kwargs: dict[str, Any], match: str):
with pytest.raises(ValueError, match=match):
StreamableHTTPSessionManager(app=Server("test"), **kwargs)

# Wait for the 50ms idle timeout to fire and cleanup to complete
await anyio.sleep(0.1)

# Verify via public API: old session ID now returns 404
response_messages: list[Message] = []

async def capture_send(message: Message):
response_messages.append(message)

scope_with_session = {
"type": "http",
"method": "POST",
"path": "/mcp",
"headers": [
(b"content-type", b"application/json"),
(b"mcp-session-id", session_id.encode()),
],
}

await manager.handle_request(scope_with_session, mock_receive, capture_send)

response_start = next(
(msg for msg in response_messages if msg["type"] == "http.response.start"),
None,
)
assert response_start is not None
assert response_start["status"] == 404


def test_session_idle_timeout_rejects_non_positive():
with pytest.raises(ValueError, match="positive number"):
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=-1)
with pytest.raises(ValueError, match="positive number"):
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=0)


def test_session_idle_timeout_rejects_stateless():
with pytest.raises(RuntimeError, match="not supported in stateless"):
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True)