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
6 changes: 6 additions & 0 deletions docs/run/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ Each transport has its own keyword arguments, all on `run()`:
* `max_request_body_size`: largest accepted request body in bytes. Defaults to 4 MiB; larger requests
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
exceed that size.
* `session_idle_timeout`: how long, in seconds, a [legacy](legacy-clients.md) (session-based)
client's session may sit with no request in flight before the server closes it. Defaults to 1800
(30 minutes); `None` keeps sessions until the client deletes them. A client with an open `GET`
stream or a request still being answered is never idle.
* `max_sessions`: how many such sessions one app holds at once. Defaults to 10 000; while that many
are open, a request that would open another gets HTTP 503. `None` removes the limit.
* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[Deploy & scale](deploy.md)** covers `transport_security`.

!!! warning
Expand Down
7 changes: 7 additions & 0 deletions docs/run/legacy-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ On one worker that is invisible. On two, it is the whole problem: a request that
events to a client reconnecting to the *same* session), not a session store. It never makes a
session reachable from another process.

The record is not kept forever. A client that ends its session (`DELETE`) frees it at once;
a session that has had no request in flight for `session_idle_timeout` seconds (default 1800; an
open `GET` stream or a request being answered counts as in flight) is closed, and its next request
gets the same `404` a stray ID gets, so the client has to `initialize` again. Each worker process
holds at most `max_sessions` of them (default 10 000) and answers `503` to a request that would
open one more. Both are `run()` / `streamable_http_app()` options.

## The one knob: `stateless_http`

If stickiness is a cost you refuse to pay, there is exactly one thing you can change.
Expand Down
8 changes: 4 additions & 4 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif

## `MCPError: Session not found`

The server does not recognise the `Mcp-Session-Id` your client sent, almost always because the server **restarted** (or you were routed to a different instance). Sessions live in that one process's memory.
The server does not recognise the `Mcp-Session-Id` your client sent, because the server **restarted** (or you were routed to a different instance), or because the session **expired**: a legacy session with no request in flight for `session_idle_timeout` (30 minutes by default; an open `GET` stream or a request being answered counts as in flight) is closed, as is one the client ended with `DELETE`. Sessions live in that one process's memory.

There is no server bug to find. The HTTP response is a `404` whose body *is* JSON-RPC, so, unlike the `421` above, the python `Client` shows you this one verbatim:

Expand All @@ -256,9 +256,9 @@ There is no server bug to find. The HTTP response is a `404` whose body *is* JSO

The fix is to reconnect: leave the `async with Client(...)` block and enter a new one, which negotiates a fresh session. For a long-lived client, that means catching `MCPError` around your calls and reconnecting on this message rather than retrying inside a dead session.

If it happens *without* a restart, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`).
If it happens *without* a restart and without the client having gone quiet that long, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`).

For the server operator, the matching log line is `Rejected request with unknown or expired session ID: <id>`. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting.
For the server operator, the matching log line is `Rejected request with unknown or expired session ID: <id>`. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting. When the session expired instead, that line is preceded by `Session <id> idle timeout`, also at `INFO`.

## `MCPError: Method not found`

Expand Down Expand Up @@ -411,7 +411,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: <host>` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`.
* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.
* `Session not found` -> the server restarted; reconnect.
* `Session not found` -> the server restarted or the session expired (`session_idle_timeout`); reconnect.
* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, `stateless_http=True` takes away the legacy one, and `json_response=True` takes away the request-scoped one. Use a resolver (a legacy client also needs a server that keeps the channel). Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have.
* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.
11 changes: 10 additions & 1 deletion src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ async def main():
from mcp.server.models import InitializationOptions
from mcp.server.runner import serve_dual_era_loop
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
from mcp.server.streamable_http_manager import (
DEFAULT_MAX_SESSIONS,
DEFAULT_SESSION_IDLE_TIMEOUT,
StreamableHTTPASGIApp,
StreamableHTTPSessionManager,
)
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.exceptions import MCPDeprecationWarning
Expand Down Expand Up @@ -722,6 +727,8 @@ def streamable_http_app(
event_store: EventStore | None = None,
retry_interval: int | None = None,
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT,
max_sessions: int | None = DEFAULT_MAX_SESSIONS,
transport_security: TransportSecuritySettings | None = None,
host: str = "127.0.0.1",
auth: AuthSettings | None = None,
Expand All @@ -747,6 +754,8 @@ def streamable_http_app(
stateless=stateless_http,
security_settings=transport_security,
max_request_body_size=max_request_body_size,
session_idle_timeout=session_idle_timeout,
max_sessions=max_sessions,
)
self._session_manager = session_manager

Expand Down
16 changes: 15 additions & 1 deletion src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.streamable_http_manager import (
DEFAULT_MAX_SESSIONS,
DEFAULT_SESSION_IDLE_TIMEOUT,
StreamableHTTPSessionManager,
)
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
from mcp.shared.exceptions import MCPError
Expand Down Expand Up @@ -388,6 +392,8 @@ def run(
event_store: EventStore | None = ...,
retry_interval: int | None = ...,
max_request_body_size: int = ...,
session_idle_timeout: float | None = ...,
max_sessions: int | None = ...,
transport_security: TransportSecuritySettings | None = ...,
) -> None: ...

Expand Down Expand Up @@ -1106,6 +1112,8 @@ async def run_streamable_http_async( # pragma: no cover
event_store: EventStore | None = None,
retry_interval: int | None = None,
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT,
max_sessions: int | None = DEFAULT_MAX_SESSIONS,
transport_security: TransportSecuritySettings | None = None,
) -> None:
"""Run the server using StreamableHTTP transport."""
Expand All @@ -1118,6 +1126,8 @@ async def run_streamable_http_async( # pragma: no cover
event_store=event_store,
retry_interval=retry_interval,
max_request_body_size=max_request_body_size,
session_idle_timeout=session_idle_timeout,
max_sessions=max_sessions,
transport_security=transport_security,
host=host,
)
Expand Down Expand Up @@ -1270,6 +1280,8 @@ def streamable_http_app(
event_store: EventStore | None = None,
retry_interval: int | None = None,
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
session_idle_timeout: float | None = DEFAULT_SESSION_IDLE_TIMEOUT,
max_sessions: int | None = DEFAULT_MAX_SESSIONS,
transport_security: TransportSecuritySettings | None = None,
host: str = "127.0.0.1",
) -> Starlette:
Expand All @@ -1281,6 +1293,8 @@ def streamable_http_app(
event_store=event_store,
retry_interval=retry_interval,
max_request_body_size=max_request_body_size,
session_idle_timeout=session_idle_timeout,
max_sessions=max_sessions,
transport_security=transport_security,
host=host,
auth=self.settings.auth,
Expand Down
49 changes: 46 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, finite 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 not (math.isfinite(idle_timeout) and idle_timeout > 0):
raise ValueError("idle_timeout must be a positive, finite 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,32 @@ 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

if self.idle_scope.cancel_called:
# The idle period already ran out and the host is ending this
# session: answer as terminated rather than dispatch into a
# message loop that is going away.
if not self._terminated:
await self.terminate()
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 +834,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 +1036,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
Loading
Loading