feat: add idle timeout for StreamableHTTP sessions (#1994)
This commit is contained in:
parent
67458948ae
commit
23a615783e
2 changed files with 135 additions and 20 deletions
|
|
@ -180,6 +180,8 @@ class StreamableHTTPServerTransport:
|
|||
] = {}
|
||||
self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[dict[str, str]]] = {}
|
||||
self._terminated = False
|
||||
# Idle timeout cancel scope; managed by the session manager.
|
||||
self.idle_scope: anyio.CancelScope | None = None
|
||||
|
||||
@property
|
||||
def is_terminated(self) -> bool:
|
||||
|
|
@ -773,8 +775,12 @@ class StreamableHTTPServerTransport:
|
|||
"""Terminate the current session, closing all streams.
|
||||
|
||||
Once terminated, all requests with this session ID will receive 404 Not Found.
|
||||
Calling this method multiple times is safe (idempotent).
|
||||
"""
|
||||
|
||||
if self._terminated: # pragma: no cover
|
||||
return
|
||||
|
||||
self._terminated = True
|
||||
logger.info(f"Terminating session: {self.mcp_session_id}")
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,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
|
||||
|
||||
Important: Only one StreamableHTTPSessionManager instance should be created
|
||||
per application. The instance cannot be reused after its run() context has
|
||||
|
|
@ -45,16 +46,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.
|
||||
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__(
|
||||
|
|
@ -65,13 +70,20 @@ class StreamableHTTPSessionManager:
|
|||
stateless: bool = False,
|
||||
security_settings: TransportSecuritySettings | None = None,
|
||||
retry_interval: int | None = None,
|
||||
session_idle_timeout: float | None = None,
|
||||
):
|
||||
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")
|
||||
|
||||
self.app = app
|
||||
self.event_store = event_store
|
||||
self.json_response = json_response
|
||||
self.stateless = stateless
|
||||
self.security_settings = security_settings
|
||||
self.retry_interval = retry_interval
|
||||
self.session_idle_timeout = session_idle_timeout
|
||||
|
||||
# Session tracking (only used if not stateless)
|
||||
self._session_creation_lock = anyio.Lock()
|
||||
|
|
@ -219,6 +231,9 @@ class StreamableHTTPSessionManager:
|
|||
if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances: # pragma: no cover
|
||||
transport = self._server_instances[request_mcp_session_id]
|
||||
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
|
||||
await transport.handle_request(scope, receive, send)
|
||||
return
|
||||
|
||||
|
|
@ -245,19 +260,31 @@ class StreamableHTTPSessionManager:
|
|||
read_stream, write_stream = streams
|
||||
task_status.started()
|
||||
try:
|
||||
await self.app.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
self.app.create_initialization_options(),
|
||||
stateless=False, # Stateful mode
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Session {http_transport.mcp_session_id} crashed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
# Use a cancel scope for idle timeout — when the
|
||||
# deadline passes the scope cancels app.run() 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
|
||||
|
||||
with idle_scope:
|
||||
await self.app.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
self.app.create_initialization_options(),
|
||||
stateless=False,
|
||||
)
|
||||
|
||||
if idle_scope.cancelled_caught:
|
||||
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")
|
||||
finally:
|
||||
# Only remove from instances if not terminated
|
||||
if ( # pragma: no branch
|
||||
http_transport.mcp_session_id
|
||||
and http_transport.mcp_session_id in self._server_instances
|
||||
|
|
|
|||
Loading…
Reference in a new issue