Skip to content

Commit e921e8f

Browse files
committed
Make session_idle_timeout usable without aborting active requests
The StreamableHTTP idle-timeout feature was implemented on the session manager but not exposed through the canonical streamable_http_app() entrypoints, pushing users toward lower-level wiring. More importantly, the idle deadline could fire while a request was still in flight, terminating the session mid-handler even though the feature is documented as reaping sessions that receive no HTTP requests. This threads session_idle_timeout through the public app builders, pauses idle reaping while requests are active, restores the deadline after the last request completes, and adds regression coverage for both passthrough and long-running request behavior. Constraint: Must preserve existing idle-session cleanup semantics once no requests remain in flight Rejected: Expose session_idle_timeout without changing runtime semantics | leaves the documented feature behavior observably incorrect Confidence: medium Scope-risk: moderate Reversibility: clean Directive: If idle reaping changes again, verify both public API reachability and active-request semantics together Tested: uv run --frozen pytest tests/server/test_streamable_http_manager.py; uv run --frozen ruff check src/mcp/server/lowlevel/server.py src/mcp/server/mcpserver/server.py src/mcp/server/streamable_http.py src/mcp/server/streamable_http_manager.py tests/server/test_streamable_http_manager.py; uv run --frozen pyright src/mcp/server/lowlevel/server.py src/mcp/server/mcpserver/server.py src/mcp/server/streamable_http.py src/mcp/server/streamable_http_manager.py Not-tested: Full integration matrix outside the focused StreamableHTTP manager suite
1 parent 3d7b311 commit e921e8f

File tree

5 files changed

+98
-7
lines changed

5 files changed

+98
-7
lines changed

src/mcp/server/lowlevel/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,7 @@ def streamable_http_app(
567567
stateless_http: bool = False,
568568
event_store: EventStore | None = None,
569569
retry_interval: int | None = None,
570+
session_idle_timeout: float | None = None,
570571
transport_security: TransportSecuritySettings | None = None,
571572
host: str = "127.0.0.1",
572573
auth: AuthSettings | None = None,
@@ -588,6 +589,7 @@ def streamable_http_app(
588589
app=self,
589590
event_store=event_store,
590591
retry_interval=retry_interval,
592+
session_idle_timeout=session_idle_timeout,
591593
json_response=json_response,
592594
stateless=stateless_http,
593595
security_settings=transport_security,

src/mcp/server/mcpserver/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,7 @@ def streamable_http_app(
10501050
stateless_http: bool = False,
10511051
event_store: EventStore | None = None,
10521052
retry_interval: int | None = None,
1053+
session_idle_timeout: float | None = None,
10531054
transport_security: TransportSecuritySettings | None = None,
10541055
host: str = "127.0.0.1",
10551056
) -> Starlette:
@@ -1060,6 +1061,7 @@ def streamable_http_app(
10601061
stateless_http=stateless_http,
10611062
event_store=event_store,
10621063
retry_interval=retry_interval,
1064+
session_idle_timeout=session_idle_timeout,
10631065
transport_security=transport_security,
10641066
host=host,
10651067
auth=self.settings.auth,

src/mcp/server/streamable_http.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import logging
10+
import math
1011
import re
1112
from abc import ABC, abstractmethod
1213
from collections.abc import AsyncGenerator, Awaitable, Callable
@@ -171,9 +172,27 @@ def __init__(
171172
] = {}
172173
self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[dict[str, str]]] = {}
173174
self._terminated = False
175+
self._active_request_count = 0
174176
# Idle timeout cancel scope; managed by the session manager.
175177
self.idle_scope: anyio.CancelScope | None = None
176178

179+
def mark_request_started(self) -> None:
180+
"""Suspend idle reaping while at least one HTTP request is in flight."""
181+
self._active_request_count += 1
182+
if self.idle_scope is not None:
183+
self.idle_scope.deadline = math.inf
184+
185+
def mark_request_finished(self, idle_timeout_seconds: float | None) -> None:
186+
"""Resume idle reaping once the last in-flight request completes."""
187+
self._active_request_count = max(0, self._active_request_count - 1)
188+
if (
189+
idle_timeout_seconds is not None
190+
and self.idle_scope is not None
191+
and self._active_request_count == 0
192+
and not self._terminated
193+
):
194+
self.idle_scope.deadline = anyio.current_time() + idle_timeout_seconds
195+
177196
@property
178197
def is_terminated(self) -> bool:
179198
"""Check if this transport has been explicitly terminated."""

src/mcp/server/streamable_http_manager.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,10 +196,11 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
196196
if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances:
197197
transport = self._server_instances[request_mcp_session_id]
198198
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
202-
await transport.handle_request(scope, receive, send)
199+
transport.mark_request_started()
200+
try:
201+
await transport.handle_request(scope, receive, send)
202+
finally:
203+
transport.mark_request_finished(self.session_idle_timeout)
203204
return
204205

205206
if request_mcp_session_id is None:
@@ -223,7 +224,6 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
223224
async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None:
224225
async with http_transport.connect() as streams:
225226
read_stream, write_stream = streams
226-
task_status.started()
227227
try:
228228
# Use a cancel scope for idle timeout — when the
229229
# deadline passes the scope cancels app.run() and
@@ -234,6 +234,8 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
234234
idle_scope.deadline = anyio.current_time() + self.session_idle_timeout
235235
http_transport.idle_scope = idle_scope
236236

237+
task_status.started()
238+
237239
with idle_scope:
238240
await self.app.run(
239241
read_stream,
@@ -267,7 +269,11 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
267269
await self._task_group.start(run_server)
268270

269271
# Handle the HTTP request and return the response
270-
await http_transport.handle_request(scope, receive, send)
272+
http_transport.mark_request_started()
273+
try:
274+
await http_transport.handle_request(scope, receive, send)
275+
finally:
276+
http_transport.mark_request_finished(self.session_idle_timeout)
271277
else:
272278
# Unknown or expired session ID - return 404 per MCP spec
273279
# TODO: Align error code once spec clarifies

tests/server/test_streamable_http_manager.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,31 @@
22

33
import json
44
import logging
5+
from contextlib import asynccontextmanager
56
from typing import Any
67
from unittest.mock import AsyncMock, patch
78

89
import anyio
910
import httpx
1011
import pytest
12+
from starlette.applications import Starlette
13+
from starlette.routing import Mount
1114
from starlette.types import Message
1215

1316
from mcp import Client
1417
from mcp.client.streamable_http import streamable_http_client
1518
from mcp.server import Server, ServerRequestContext, streamable_http_manager
1619
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport
1720
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
18-
from mcp.types import INVALID_REQUEST, ListToolsResult, PaginatedRequestParams
21+
from mcp.types import (
22+
INVALID_REQUEST,
23+
CallToolRequestParams,
24+
CallToolResult,
25+
ListToolsResult,
26+
PaginatedRequestParams,
27+
TextContent,
28+
Tool,
29+
)
1930

2031

2132
@pytest.mark.anyio
@@ -413,3 +424,54 @@ def test_session_idle_timeout_rejects_non_positive():
413424
def test_session_idle_timeout_rejects_stateless():
414425
with pytest.raises(RuntimeError, match="not supported in stateless"):
415426
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True)
427+
428+
429+
def test_streamable_http_app_exposes_session_idle_timeout():
430+
app = Server("test-streamable-http-app")
431+
432+
starlette_app = app.streamable_http_app(session_idle_timeout=30)
433+
434+
assert starlette_app is not None
435+
assert app.session_manager.session_idle_timeout == 30
436+
437+
438+
@pytest.mark.anyio
439+
async def test_session_idle_timeout_does_not_cancel_in_flight_request():
440+
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
441+
return ListToolsResult(
442+
tools=[
443+
Tool(
444+
name="slow",
445+
description="Slow tool",
446+
inputSchema={"type": "object", "properties": {}},
447+
)
448+
]
449+
)
450+
451+
async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
452+
await anyio.sleep(2.0)
453+
return CallToolResult(content=[TextContent(type="text", text="ok")])
454+
455+
server = Server("idle-timeout-active-request", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
456+
manager = StreamableHTTPSessionManager(app=server, session_idle_timeout=1.0)
457+
458+
async def handle_streamable_http(scope, receive, send) -> None:
459+
await manager.handle_request(scope, receive, send)
460+
461+
@asynccontextmanager
462+
async def lifespan(app: Starlette):
463+
async with manager.run():
464+
yield
465+
466+
starlette_app = Starlette(routes=[Mount("/", app=handle_streamable_http)], lifespan=lifespan)
467+
468+
async with (
469+
starlette_app.router.lifespan_context(starlette_app),
470+
httpx.ASGITransport(starlette_app) as transport,
471+
httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client,
472+
Client(streamable_http_client("http://testserver/", http_client=http_client)) as client,
473+
):
474+
with anyio.fail_after(5):
475+
result = await client.call_tool("slow", {})
476+
477+
assert result.content[0].text == "ok"

0 commit comments

Comments
 (0)