Skip to content
Merged
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
5 changes: 5 additions & 0 deletions docs/run/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ 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`: seconds a legacy session may sit with nothing in flight before the
server closes it. Default 1800. `None` disables it. See
[Session lifetime and limits](legacy-clients.md#session-lifetime-and-limits).
* `max_sessions`: how many legacy sessions one process holds at once. Default 10 000. `None`
removes the limit. Covered in the same section.
* `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
34 changes: 34 additions & 0 deletions docs/run/legacy-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ 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.

## Session lifetime and limits

A legacy session does not live forever, and one process does not hold an unlimited number of
them. Two settings control this. Both are keyword arguments on `run()`, `streamable_http_app()`
and `Server.streamable_http_app()`. Modern (`2026-07-28`) connections and `stateless_http=True`
have no sessions, so neither setting applies to them.

| Setting | Default | What it does | What the client sees | Turn it off |
|---|---|---|---|---|
| `session_idle_timeout` | `1800` (30 min) | Closes a session that has had nothing in flight for that long. | `404 Session not found`. It has to `initialize` again. | `None` |
| `max_sessions` | `10_000` | Refuses to open a session beyond that many. Existing sessions are untouched and nothing is evicted. | `503 Too many open sessions` with JSON-RPC code `-32603`. | `None` |

What counts as "in flight":

* An open `GET` stream. The SDK clients keep one open, so a connected client's session never
expires.
* A request that is still being answered. A tool call that runs longer than the timeout is not
interrupted, and the countdown only starts once it finishes.
* Nothing else. Between requests the clock runs. Any request on the session restarts it,
`ping` included. Once a session has expired, nothing revives it.

A client that ends its session with `DELETE` frees it immediately. So does a client whose
opening request was refused.

```python
mcp.run(transport="streamable-http", session_idle_timeout=None, max_sessions=50_000)
```

Both events show up in the server log. An expiry is `Session <id> idle timeout` at `INFO`. A
refused open is `Refusing to open a new session: <n> sessions are already open` at `WARNING`.

The limits are per process. With four workers the ceiling is four times `max_sessions`, and each
worker expires its own sessions.

## 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. Either the server **restarted** (or you were routed to a different instance), or the session **expired** because nothing was in flight for `session_idle_timeout`, which is 30 minutes by default. See [Session lifetime and limits](run/legacy-clients.md#session-lifetime-and-limits). Sessions live in that one process's memory.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A client DELETE also removes the session immediately, so its next request can produce this 404 without a restart or idle timeout. Mention client-ended sessions here, as the linked lifecycle page does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/troubleshooting.md, line 249:

<comment>A client `DELETE` also removes the session immediately, so its next request can produce this 404 without a restart or idle timeout. Mention client-ended sessions here, as the linked lifecycle page does.</comment>

<file context>
@@ -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, 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.
+The server does not recognise the `Mcp-Session-Id` your client sent. Either the server **restarted** (or you were routed to a different instance), or the session **expired** because nothing was in flight for `session_idle_timeout`, which is 30 minutes by default. See [Session lifetime and limits](run/legacy-clients.md#session-lifetime-and-limits). 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:
</file context>
Suggested change
The server does not recognise the `Mcp-Session-Id` your client sent. Either the server **restarted** (or you were routed to a different instance), or the session **expired** because nothing was in flight for `session_idle_timeout`, which is 30 minutes by default. See [Session lifetime and limits](run/legacy-clients.md#session-lifetime-and-limits). Sessions live in that one process's memory.
The server does not recognise the `Mcp-Session-Id` your client sent. Either the server **restarted** (or you were routed to a different instance), the client ended the session with `DELETE`, or the session **expired** because nothing was in flight for `session_idle_timeout`, which is 30 minutes by default. See [Session lifetime and limits](run/legacy-clients.md#session-lifetime-and-limits). 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