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
Expire idle Streamable HTTP sessions by default
`session_idle_timeout` was opt-in (default None), so at stock settings a
stateful session that its client never deleted stayed registered, with
its server task and streams, until the process exited. The docstring
already recommended 1800 seconds; make that the default
(DEFAULT_SESSION_IDLE_TIMEOUT) so sessions nobody is using are
reclaimed after 30 minutes. `None` keeps the previous behaviour.

"Idle" is now measured from the moment the session's last in-flight
request completes rather than from the arrival of the last request:
the transport takes an `idle_timeout` and owns the countdown, holding
it while any request (an open GET stream included) is being served and
restarting it when the last one finishes. A connected client, or a call
that runs longer than the timeout, therefore never loses its session;
a client that goes quiet with no stream open gets 404 on its next
request and initializes again, as the spec describes.

The timeout is simply unused in stateless mode, which keeps no
sessions, so constructing a stateless manager with a timeout no longer
raises.
  • Loading branch information
maxisbey committed Aug 26, 2026
commit 373e956b6feae5743290536e96471878d517f50f
40 changes: 37 additions & 3 deletions src/mcp/server/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""

import logging
import math
import re
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Awaitable, Callable
Expand Down Expand Up @@ -167,6 +168,7 @@ def __init__(
event_store: EventStore | None = None,
security_settings: TransportSecuritySettings | None = None,
retry_interval: int | None = None,
idle_timeout: float | None = None,
) -> None:
"""Initialize a new StreamableHTTP server transport.

Expand All @@ -187,12 +189,22 @@ def __init__(
retry field. When set, the server will send a retry field in
SSE priming events to control client reconnection timing for
polling behavior. Only used when event_store is provided.
idle_timeout: Seconds the session may go without any request in flight before
`idle_scope` is cancelled. A request being served or an open GET
stream holds the session open; the countdown starts each time the
last in-flight request completes. The host enters `idle_scope`
(available once `connect()` has been entered) around the session's
message loop to end the session when it fires. Default is None: no
`idle_scope`, the session never expires.

Raises:
ValueError: If the session ID contains invalid characters.
ValueError: If the session ID contains invalid characters, or if `idle_timeout`
is not a positive number.
"""
if mcp_session_id is not None and not SESSION_ID_PATTERN.fullmatch(mcp_session_id):
raise ValueError("Session ID must only contain visible ASCII characters (0x21-0x7E)")
if idle_timeout is not None and idle_timeout <= 0:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
raise ValueError("idle_timeout must be a positive number of seconds")

self.mcp_session_id = mcp_session_id
self.is_json_response_enabled = is_json_response_enabled
Expand All @@ -208,8 +220,11 @@ def __init__(
] = {}
self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {}
self._terminated = False
# Idle timeout cancel scope; managed by the session manager.
self._idle_timeout = idle_timeout
self._requests_in_flight = 0
self.idle_scope: anyio.CancelScope | None = None
"""Created when `connect()` is entered if `idle_timeout` is set; cancelled once no request has been in
flight for `idle_timeout` seconds."""

@property
def is_terminated(self) -> bool:
Expand Down Expand Up @@ -458,6 +473,23 @@ async def _clean_up_memory_streams(self, request_id: RequestId) -> None:

async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Application entry point that handles all HTTP requests."""
if self.idle_scope is None or self._idle_timeout is None:
await self._handle_request(scope, receive, send)
return

# A request in flight (an open GET stream included) holds the session:
# the idle countdown is suspended while any is being served and
# restarts when the last one completes.
self._requests_in_flight += 1
self.idle_scope.deadline = math.inf
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
try:
await self._handle_request(scope, receive, send)
finally:
self._requests_in_flight -= 1
if not self._requests_in_flight:
self.idle_scope.deadline = anyio.current_time() + self._idle_timeout
Comment thread
maxisbey marked this conversation as resolved.

async def _handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
request = Request(scope, receive)

# Validate request headers for DNS rebinding protection
Expand Down Expand Up @@ -793,7 +825,7 @@ async def _handle_delete_request(self, request: Request, send: Send) -> None:
await response(request.scope, request.receive, send)
return

if not await self._validate_request_headers(request, send): # pragma: no cover
if not await self._validate_request_headers(request, send):
return

await self.terminate()
Expand Down Expand Up @@ -995,6 +1027,8 @@ async def connect(
Yields:
Tuple of (read_stream, write_stream) for bidirectional communication
"""
if self._idle_timeout is not None:
self.idle_scope = anyio.CancelScope()

# Create the memory streams for this connection

Expand Down
42 changes: 20 additions & 22 deletions src/mcp/server/streamable_http_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import contextlib
import logging
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Final
from uuid import uuid4

import anyio
Expand Down Expand Up @@ -34,6 +34,9 @@

logger = logging.getLogger(__name__)

DEFAULT_SESSION_IDLE_TIMEOUT: Final = 30 * 60
"""Default idle period in seconds after which a stateful Streamable HTTP session is closed (30 minutes)."""


class StreamableHTTPSessionManager:
"""Manages StreamableHTTP sessions with optional resumability via event store.
Expand All @@ -45,7 +48,7 @@ class StreamableHTTPSessionManager:
2. Resumability via an optional event store
3. Connection management and lifecycle
4. Request handling and transport setup
5. Idle session cleanup via optional timeout
5. Idle session cleanup

Important: Only one StreamableHTTPSessionManager instance should be created
per application. The instance cannot be reused after its run() context has
Expand All @@ -62,11 +65,12 @@ class StreamableHTTPSessionManager:
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.
session_idle_timeout: Idle timeout in seconds for stateful sessions. A session that has had no HTTP
request in flight for this long (no request being served, no open GET stream) is terminated and
removed; its ID then answers 404 and the client has to initialize a new session. When retry_interval
is also configured, ensure the idle timeout comfortably exceeds the retry interval to avoid reaping
sessions during normal SSE polling gaps. Defaults to 1800 (30 minutes); None disables the timeout so
sessions live until the client deletes them or the manager shuts down. Unused in stateless mode.
max_request_body_size: Maximum size in bytes for Streamable HTTP request bodies. Requests that
exceed this limit receive a 413 response before parsing or session creation. Defaults to 4 MiB.
"""
Expand All @@ -79,13 +83,11 @@ def __init__(
stateless: bool = False,
security_settings: TransportSecuritySettings | None = None,
retry_interval: int | None = None,
session_idle_timeout: float | None = None,
session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT,
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
):
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 RuntimeError("session_idle_timeout is not supported in stateless mode")
if max_request_body_size <= 0:
raise ValueError("max_request_body_size must be a positive number of bytes")

Expand Down Expand Up @@ -274,9 +276,6 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
await response(scope, receive, send)
return
logger.debug("Session already exists, handling request directly")
# Push back idle deadline on activity
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)
if transport.is_terminated:
# The client ended the session (DELETE): forget it now rather
Expand All @@ -295,6 +294,7 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
event_store=self.event_store, # May be None (no resumability)
security_settings=self.security_settings,
retry_interval=self.retry_interval,
idle_timeout=self.session_idle_timeout,
)

assert http_transport.mcp_session_id is not None
Expand All @@ -309,15 +309,13 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
read_stream, write_stream = streams
task_status.started()
try:
# Use a cancel scope for idle timeout — when the
# deadline passes the scope cancels the loop and
# execution continues after the ``with`` block.
# Incoming requests push the deadline forward.
idle_scope = anyio.CancelScope()
if self.session_idle_timeout is not None:
idle_scope.deadline = anyio.current_time() + self.session_idle_timeout
http_transport.idle_scope = idle_scope

# The transport cancels its idle scope once no request
# has been in flight for `session_idle_timeout`; that
# ends the loop and execution continues after the
# `with` block. Without a timeout there is nothing to fire.
idle_scope = http_transport.idle_scope
if idle_scope is None:
idle_scope = anyio.CancelScope()
with idle_scope:
# Drive via `serve_loop` (not `Server.run()`) so the
# manager's already-entered lifespan is reused
Expand Down
Loading