Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ def streamable_http_app(
stateless_http: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
session_idle_timeout: float | None = None,
transport_security: TransportSecuritySettings | None = None,
host: str = "127.0.0.1",
auth: AuthSettings | None = None,
Expand All @@ -588,6 +589,7 @@ def streamable_http_app(
app=self,
event_store=event_store,
retry_interval=retry_interval,
session_idle_timeout=session_idle_timeout,
json_response=json_response,
stateless=stateless_http,
security_settings=transport_security,
Expand Down
5 changes: 5 additions & 0 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ def run(
stateless_http: bool = ...,
event_store: EventStore | None = ...,
retry_interval: int | None = ...,
session_idle_timeout: float | None = ...,
transport_security: TransportSecuritySettings | None = ...,
) -> None: ...

Expand Down Expand Up @@ -889,6 +890,7 @@ async def run_streamable_http_async( # pragma: no cover
stateless_http: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
session_idle_timeout: float | None = None,
transport_security: TransportSecuritySettings | None = None,
) -> None:
"""Run the server using StreamableHTTP transport."""
Expand All @@ -900,6 +902,7 @@ async def run_streamable_http_async( # pragma: no cover
stateless_http=stateless_http,
event_store=event_store,
retry_interval=retry_interval,
session_idle_timeout=session_idle_timeout,
transport_security=transport_security,
host=host,
)
Expand Down Expand Up @@ -1047,6 +1050,7 @@ def streamable_http_app(
stateless_http: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
session_idle_timeout: float | None = None,
transport_security: TransportSecuritySettings | None = None,
host: str = "127.0.0.1",
) -> Starlette:
Expand All @@ -1057,6 +1061,7 @@ def streamable_http_app(
stateless_http=stateless_http,
event_store=event_store,
retry_interval=retry_interval,
session_idle_timeout=session_idle_timeout,
transport_security=transport_security,
host=host,
auth=self.settings.auth,
Expand Down
24 changes: 19 additions & 5 deletions src/mcp/server/streamable_http_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import contextlib
import logging
import math
from collections.abc import AsyncIterator
from http import HTTPStatus
from typing import TYPE_CHECKING, Any
Expand Down Expand Up @@ -196,10 +197,15 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances:
transport = self._server_instances[request_mcp_session_id]
logger.debug("Session already exists, handling request directly")
# Push back idle deadline on activity
# Suspend idle deadline while the request is in-flight so a slow
# handler cannot be cancelled mid-execution, then reset after.
if transport.idle_scope is not None and self.session_idle_timeout is not None:
transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout # pragma: no cover
await transport.handle_request(scope, receive, send)
transport.idle_scope.deadline = math.inf
try:
await transport.handle_request(scope, receive, send)
finally:
if transport.idle_scope is not None and self.session_idle_timeout is not None:
transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout
return

if request_mcp_session_id is None:
Expand Down Expand Up @@ -266,8 +272,16 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
# Start the server task
await self._task_group.start(run_server)

# Handle the HTTP request and return the response
await http_transport.handle_request(scope, receive, send)
# Suspend idle deadline while the request is in-flight so a slow
# handler cannot be cancelled mid-execution, then reset after.
if http_transport.idle_scope is not None and self.session_idle_timeout is not None:
http_transport.idle_scope.deadline = math.inf
try:
# Handle the HTTP request and return the response
await http_transport.handle_request(scope, receive, send)
finally:
if http_transport.idle_scope is not None and self.session_idle_timeout is not None:
http_transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout
else:
# Unknown or expired session ID - return 404 per MCP spec
# TODO: Align error code once spec clarifies
Expand Down
64 changes: 64 additions & 0 deletions tests/server/test_streamable_http_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,67 @@ def test_session_idle_timeout_rejects_non_positive():
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)


def test_streamable_http_app_accepts_session_idle_timeout():
"""session_idle_timeout is forwarded from streamable_http_app() to the session manager."""
app = Server("test-passthrough")
starlette_app = app.streamable_http_app(host="testserver", session_idle_timeout=30.0)
assert starlette_app is not None
assert app._session_manager is not None
assert app._session_manager.session_idle_timeout == 30.0


@pytest.mark.anyio
async def test_active_request_not_cancelled_by_idle_timeout():
"""A request whose handler takes longer than session_idle_timeout must still complete."""
host = "testserver"
IDLE_TIMEOUT = 0.05 # 50 ms
HANDLER_SLEEP = 0.15 # 150 ms — longer than the timeout

tool_completed = False

async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
nonlocal tool_completed
await anyio.sleep(HANDLER_SLEEP)
tool_completed = True
return ListToolsResult(tools=[])

app = Server("test-no-cancel", on_list_tools=handle_list_tools)
mcp_app = app.streamable_http_app(host=host, session_idle_timeout=IDLE_TIMEOUT)
async with (
mcp_app.router.lifespan_context(mcp_app),
httpx.ASGITransport(mcp_app) as transport,
httpx.AsyncClient(transport=transport) as http_client,
Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client)) as client,
):
result = await client.list_tools()
assert tool_completed, "Handler should have completed without being cancelled"
assert result.tools == []


@pytest.mark.anyio
async def test_idle_session_reaped_after_request_completes():
"""After the last request finishes, idle reaping still works normally."""
host = "testserver"
IDLE_TIMEOUT = 0.05 # 50 ms

async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[])

app = Server("test-reap-after-request", on_list_tools=handle_list_tools)
mcp_app = app.streamable_http_app(host=host, session_idle_timeout=IDLE_TIMEOUT)
async with (
mcp_app.router.lifespan_context(mcp_app),
httpx.ASGITransport(mcp_app) as transport,
httpx.AsyncClient(transport=transport) as http_client,
Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client)) as client,
):
await client.list_tools()
# Wait well past the idle timeout
await anyio.sleep(IDLE_TIMEOUT * 4)
session_manager = app._session_manager
assert session_manager is not None
assert len(session_manager._server_instances) == 0, (
"Session should have been reaped after idle timeout elapsed post-request"
)
Loading