Back to Fastmcp

Stateless session state (2026-07-28)

docs/development/v4-notes/stateless-session-state.md

4.0.0a210.6 KB
Original Source

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.