docs/development/v4-notes/stateless-session-state.md
Design spec. Status: building.
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.
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.
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.
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.
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.
Session objectAsync 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.)
SessionProviderSession 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:
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.
Keyed by (principal, session_id):
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.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.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:
Scope and the scope= parameter; revert ctx.get_state/
set_state to their original request-scoped behavior.SessionCodec/sealing — ids are bare uuid4.Session object with async get/set/delete/clear over the server
store, single-dict-per-session key scheme.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.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).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.end_session.Written against the final API once the rework verifies:
docs.json).ctx.session_id / set_state.