Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
Loading
Loading