mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-17 19:19:12 +02:00
217 lines
11 KiB
Markdown
217 lines
11 KiB
Markdown
# Stateless session state (2026-07-28)
|
|
|
|
> Design spec. Status: building.
|
|
|
|
## Problem
|
|
|
|
The `2026-07-28` era is stateless by protocol construction: each request builds a
|
|
fresh `Connection`, `connection.session_id` is always `None`, and
|
|
`connection.state` is a new dict discarded when the request returns. So
|
|
`ctx.session_id` mints a throwaway `uuid4` per request and `ctx.set_state` /
|
|
`ctx.get_state` **silently never round-trip** — no error, just lost data. A user
|
|
who wants cross-call state (a cart, a conversation, accumulated context) has no
|
|
safe mechanism, and the failure is invisible.
|
|
|
|
The one identifier every modern request carries that is stable and
|
|
**non-spoofable** is the authenticated principal — `get_access_token().claims["sub"]`,
|
|
or the `(client_id, issuer, subject)` triple. Everything else on the wire is
|
|
client-declared and forgeable.
|
|
|
|
## The model
|
|
|
|
State lives **server-side** in the one `AsyncKeyValue` (py-key-value) store the
|
|
server already holds (`session_state_store`). The framework calls `get`/`put`/
|
|
`delete` and **never imposes a TTL** — retention is entirely the store's
|
|
(configure it on the store you pass: a Redis TTL, a py-key-value TTL wrapper,
|
|
whatever). There is no second store and no framework-owned TTL knob.
|
|
|
|
Isolation comes from the **authenticated principal, not from the session id.**
|
|
State is keyed by `(principal, session_id)`. A request under principal B keys
|
|
into B's own namespace — it can never address A's keys no matter what
|
|
`session_id` it passes. The id only organizes sessions *within* a principal. The
|
|
handle is a bare `uuid4` string; it is **not sealed** — the principal prefix is
|
|
the wall. Sessions are also create-then-validate (below): an id that was never
|
|
minted by `create_session` under this principal is rejected outright, not
|
|
resolved to an empty session.
|
|
|
|
## Two explicit patterns
|
|
|
|
A tool opts into exactly one, on purpose. There is deliberately **no** optional
|
|
"id if given, else default" parameter — that would silently misroute a call
|
|
whose id the agent forgot to pass into the shared per-user bucket, which is the
|
|
invisible-degradation failure this whole feature exists to remove.
|
|
|
|
### Per-user state — injected
|
|
|
|
```python
|
|
from fastmcp.server.sessions import UserSession
|
|
|
|
@mcp.tool
|
|
async def remember(fact: str, session: UserSession) -> str:
|
|
await session.set("fact", fact)
|
|
return "noted"
|
|
```
|
|
|
|
`session: UserSession` is **dependency-injected** (like `ctx: Context`): keyed by
|
|
the request's authenticated principal, not present in the input schema, nothing
|
|
for the agent to pass. Requires auth — with no principal it raises a clear error.
|
|
Use it when one bucket per user is what you want. `UserSession` is only the
|
|
injection annotation — the value the handler receives is an ordinary `Session`,
|
|
so its `get`/`set`/`delete`/`clear` accessors work as usual.
|
|
|
|
### Distinct sessions — an argument
|
|
|
|
```python
|
|
from fastmcp.server.sessions import SessionId
|
|
from fastmcp.server.dependencies import get_session
|
|
|
|
@mcp.tool
|
|
async def add_to_cart(item: str, session_id: SessionId) -> str:
|
|
session = await get_session(session_id)
|
|
cart = await session.get("cart", default=[])
|
|
cart.append(item)
|
|
await session.set("cart", cart)
|
|
return f"{len(cart)} items"
|
|
```
|
|
|
|
`session_id: SessionId` is a **required string argument** — it *is* in the schema,
|
|
the agent supplies it. `SessionId` is a marker type so the framework
|
|
auto-populates the argument's description with the protocol:
|
|
|
|
> "Session identifier. Use a tool to create a session, then pass the resulting id
|
|
> here to persist state across calls in the same session."
|
|
|
|
The tool becomes self-teaching — an agent reads the schema and learns the
|
|
create-then-pass contract with no hand-prompting. The description names no
|
|
specific tool: composition can rename the lifecycle tool (mounting under a
|
|
namespace exposes it as `child_create_session`), so it points at the
|
|
*capability* rather than a name that may not exist under that mount.
|
|
|
|
The standalone `await get_session(session_id)` resolves the id to a `Session`
|
|
keyed by `(principal, session_id)`, **validating** that it was created under this
|
|
principal — an unknown or foreign id raises `InvalidSession` rather than opening a
|
|
fresh bucket. It is a plain function, not a `Context` method, so it needs no
|
|
foreground context and works from a `task=True` tool's worker. Use this pattern
|
|
when a user needs more than one session.
|
|
|
|
## The `Session` object
|
|
|
|
Async accessors over the server store, scoped to one `(principal, session_id)`:
|
|
|
|
- `session.id` — the session's id (set for a `session_id`-resolved session; `None`
|
|
for an injected `UserSession`, which has no distinct id).
|
|
- `await session.get(key, default=None)`
|
|
- `await session.set(key, value)`
|
|
- `await session.delete(key)`
|
|
- `await session.clear()` — empties user state but **keeps the session valid**.
|
|
- `await session.end()` — deletes the session (what `end_session` calls).
|
|
|
|
A session's state is stored as a **single dict under one key**
|
|
(`session:{sha256(principal)}:{session_id}`, and `session:anon:{session_id}` when
|
|
unauthenticated — the principal is hashed into a fixed-length, delimiter-safe
|
|
segment, never embedded raw). That dict holds user state in a `state` sub-dict
|
|
alongside a small `_created` marker, so a created-but-empty session is
|
|
distinguishable from a missing one even if the store collapses empty dicts.
|
|
`get`/`set`/`delete` read-modify-write the sub-dict and never touch the marker;
|
|
`clear` resets the sub-dict but leaves the marker (the session still resolves);
|
|
`end` deletes the key. Namespacing user state under `state` is what keeps a user
|
|
key named `_created` from colliding with the marker. One key per session means
|
|
one TTL per session (the store's), refreshed on write — no key index to maintain,
|
|
and `end` is a single delete. (Trade-off: concurrent writes to one session race
|
|
on the read-modify-write; session state is small and typically driven serially by
|
|
one agent, so this is acceptable — noted, not hidden.)
|
|
|
|
## `SessionProvider`
|
|
|
|
Session ids are minted by `SessionProvider`, which contributes two tools:
|
|
|
|
- `create_session()` → mints an unguessable `uuid4`, **records** the session
|
|
under the current principal, and returns the id as a string.
|
|
- `end_session(session_id: SessionId)` → validates the id, then deletes the
|
|
session so it no longer resolves.
|
|
|
|
Register it whenever your tools take a `session_id` — providers are the idiomatic
|
|
way to add functionality like this:
|
|
|
|
```python
|
|
from fastmcp.server.sessions import SessionProvider
|
|
|
|
mcp.add_provider(SessionProvider())
|
|
```
|
|
|
|
There is **no enforcement** that a provider is registered, and there was: an
|
|
earlier version scanned the tool set at list/resolve time and raised if a
|
|
`session_id` tool had no provider. That check had to reason about the whole
|
|
composition pipeline — `isinstance` on providers, unwrapping namespaced ones,
|
|
tool transforms, session visibility, enabled state — and produced false
|
|
positives that broke valid servers (a namespaced provider, a session-disabled
|
|
tool). It was deleted. The guarantee never needed it: `get_session` validates
|
|
that an id was recorded (create-then-validate), so a server with no provider
|
|
simply cannot mint ids, and every `get_session` rejects — a misconfiguration
|
|
caught the first time the tools run, not a security hole.
|
|
|
|
`SessionProvider` subclasses `Provider`, takes **no store** (uses the server's)
|
|
and **no ttl** (the store's). It exists to mint and end owned ids.
|
|
`create_session` matters most without auth, where an unguessable id is the only
|
|
defense against a caller *guessing* onto another session.
|
|
|
|
When an application already mints its own identifiers — conversation ids, workflow
|
|
ids — take them as ordinary string arguments rather than `SessionId`, and register
|
|
no provider; `SessionId` is specifically the create-then-pass contract backed by
|
|
`create_session`.
|
|
|
|
## Security
|
|
|
|
Keyed by `(principal, session_id)`:
|
|
|
|
- **Authenticated → strong isolation.** `principal` is the validated token
|
|
subject, unforgeable. B keys into B's namespace; A's data is unreachable no
|
|
matter what id B passes. Guessing is pointless; a session id appearing in agent
|
|
context or logs is harmless (it is not a capability without the principal).
|
|
Caller-chosen ids are safe here.
|
|
- **Unauthenticated → single-tenant-safe only.** No principal, so the key is just
|
|
the id in a shared namespace: the id becomes a bearer capability, and exposure
|
|
in logs/conversation leaks the session. `create_session`'s `uuid4` gives
|
|
guess-*resistance*, not isolation. Documented in bold: not a tenant boundary;
|
|
without auth, force minted ids and never treat sessions as a wall between
|
|
clients.
|
|
- **Isolation is auth; the id is organization.** No id scheme substitutes for a
|
|
principal, which is why sealing the handle buys nothing load-bearing and is
|
|
dropped.
|
|
- **Not FastMCP's job:** transport (use TLS), encryption at rest (the store's), a
|
|
malicious *authorized* client acting within its rights.
|
|
|
|
## Rework plan (from the current prototype)
|
|
|
|
The prototype (`sessions.py`, `context.py`, `function_tool.py`, `server.py`) built
|
|
a `Scope` enum, a sealed `SessionCodec`, and `ctx.get_state(scope=...)`. Rework to
|
|
the above:
|
|
|
|
1. **Remove `Scope`** and the `scope=` parameter; revert `ctx.get_state`/
|
|
`set_state` to their original request-scoped behavior.
|
|
2. **Remove the `SessionCodec`/sealing** — ids are bare `uuid4`.
|
|
3. **`Session` object** with async `get`/`set`/`delete`/`clear` over the server
|
|
store, single-dict-per-session key scheme.
|
|
4. **`session: UserSession`** injection (principal-keyed; error without auth) —
|
|
wire into the same parameter-detection path as `Context`. `UserSession` is the
|
|
injection marker; the injected value is a `Session`.
|
|
5. **`session_id: SessionId`** marker type: string in the schema, auto-filled
|
|
description, standalone `await get_session(id)` resolver that validates the id
|
|
(works from a task worker — no foreground context needed).
|
|
6. **`SessionProvider(Provider)`** with `create_session` (records the session) /
|
|
`end_session` (deletes it), registered explicitly via `add_provider`. No
|
|
enforcement that it is present — `get_session`'s validation is the guarantee.
|
|
7. Rewrite the tests to cover both patterns, principal isolation, no-auth
|
|
behavior, and `end_session`.
|
|
|
|
## Docs plan
|
|
|
|
Written against the final API once the rework verifies:
|
|
|
|
- A concept guide — why stateless removes the session, the two patterns, when to
|
|
reach for each. Why before how.
|
|
- A security page — the two tiers, "isolation is auth, the id is organization,"
|
|
the bold no-multitenant-without-auth warning.
|
|
- Fully runnable examples for both patterns (pass the doc-import guard, register
|
|
in `docs.json`).
|
|
- A migration note from the old `ctx.session_id` / `set_state`.
|