Merge remote-tracking branch 'origin/main' into claude/mcp-background-tasks-v2-0f883f

# Conflicts:
#	fastmcp_slim/fastmcp/server/dependencies.py
This commit is contained in:
Jeremiah Lowin 2026-07-23 19:32:52 -04:00
commit 3f746b91fc
No known key found for this signature in database
20 changed files with 2058 additions and 135 deletions

View file

@ -123,7 +123,7 @@ async with client:
## Connection Lifecycle
The client uses context managers for connection management. When you enter the context, the client establishes a connection and performs an MCP initialization handshake with the server. This handshake exchanges capabilities, server metadata, and instructions.
The client uses context managers for connection management. When you enter the context, the client establishes a connection and negotiates the protocol era with the server. Metadata returned by either legacy initialization or modern discovery is exposed through the same client properties.
```python
from fastmcp import Client, FastMCP
@ -136,10 +136,12 @@ def greet(name: str) -> str:
return f"Hello, {name}!"
async with Client(mcp) as client:
# Initialization already happened automatically
print(f"Server: {client.initialize_result.server_info.name}")
print(f"Instructions: {client.initialize_result.instructions}")
print(f"Capabilities: {client.initialize_result.capabilities.tools}")
# Protocol negotiation already happened automatically
assert client.server_info is not None
assert client.server_capabilities is not None
print(f"Server: {client.server_info.name}")
print(f"Instructions: {client.instructions}")
print(f"Capabilities: {client.server_capabilities.tools}")
```
For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually:
@ -200,12 +202,16 @@ You can also pin a specific modern protocol version to adopt it directly, withou
client = Client("https://example.com/mcp", mode="2026-07-28")
```
Once connected, the negotiated version and the server's advertised capabilities are available as properties. Both are populated regardless of which era was negotiated, and both are `None` while the client is disconnected.
Once connected, the negotiated version, server identity, capabilities, and instructions are available as properties. They are populated from either the legacy `InitializeResult` or modern `DiscoverResult`, and reset to `None` when the client disconnects. `instructions` is also `None` when the server does not provide any.
When you pin a modern version directly, the client skips discovery and adopts that version with minimal synthesized metadata. In that mode, `server_info` has an empty name and `instructions` is `None`.
```python
async with Client("https://example.com/mcp", mode="auto") as client:
print(client.protocol_version) # e.g. "2026-07-28"
print(client.server_info) # Implementation | None
print(client.server_capabilities) # ServerCapabilities | None
print(client.instructions) # str | None
```
<Note>

View file

@ -0,0 +1,217 @@
# 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`.

View file

@ -160,6 +160,7 @@
"servers/dependency-injection",
"servers/lifespan",
"servers/storage-backends",
"servers/sessions",
"servers/tasks",
"servers/versioning"
]

View file

@ -23,7 +23,7 @@ The `Context` object provides a clean interface to access MCP features within yo
- **Prompt Access**: List and retrieve prompts registered with the server
- **LLM Sampling**: Request the client's LLM to generate text based on provided messages
- **User Elicitation**: Request structured input from users during tool execution
- **Session State**: Store data that persists across requests within an MCP session
- **Request State**: Pass values and non-serializable resources between middleware and handlers within a request (for state that persists across requests, see [Session State](/servers/sessions))
- **Session Visibility**: [Control which components are visible](/servers/visibility#per-session-visibility) to the current session
- **Request Information**: Access metadata about the current request
- **Server Access**: When needed, access the underlying FastMCP server instance
@ -71,7 +71,7 @@ async def data_analysis_request(dataset: str, ctx: Context = CurrentContext()) -
- Dependency parameters are automatically excluded from the MCP schema—clients never see them.
- Context methods are async, so your function usually needs to be async as well.
- **Each MCP request receives a new context object.** Request-scoped values, including non-serializable state, are not available in subsequent requests. Serializable session state stored with `ctx.set_state()` persists across requests in the same MCP session.
- **Each MCP request receives a new context object.** State set with `ctx.set_state()` is scoped to that request and is not available in subsequent ones. To persist state across requests, use [Session State](/servers/sessions).
- Context is only available during a request; attempting to use context methods outside a request will raise errors.
### Legacy Type-Hint Injection
@ -211,112 +211,62 @@ messages = result.messages
- **`ctx.list_prompts() -> list[MCPPrompt]`**: Returns list of all available prompts
- **`ctx.get_prompt(name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult`**: Get a specific prompt with optional arguments
### Session State
### Request State
<VersionBadge version="3.0.0" />
Store data that persists across multiple requests within the same MCP session. Session state is automatically keyed by the client's session, ensuring isolation between different clients.
Request state carries values *within a single request*, across the middleware → handler pipeline. A request runs through any middleware you've added and then the handler — separate functions that don't share a stack frame, so a plain local variable can't pass anything between them. `ctx.set_state` / `ctx.get_state` is that channel.
```python
from fastmcp import FastMCP, Context
mcp = FastMCP("stateful-app")
@mcp.tool
async def increment_counter(ctx: Context) -> int:
"""Increment a counter that persists across tool calls."""
count = await ctx.get_state("counter") or 0
await ctx.set_state("counter", count + 1)
return count + 1
@mcp.tool
async def get_counter(ctx: Context) -> int:
"""Get the current counter value."""
return await ctx.get_state("counter") or 0
```
Each client session has its own isolated state—two different clients calling `increment_counter` will each have their own counter.
**Method signatures:**
- **`await ctx.set_state(key, value, *, serializable=True)`**: Store a value in session state
- **`await ctx.get_state(key)`**: Retrieve a value (returns None if not found)
- **`await ctx.delete_state(key)`**: Remove a value from session state
<Note>
State methods are async and require `await`. State expires after 1 day to prevent unbounded memory growth.
</Note>
#### Non-Serializable Values
By default, state values must be JSON-serializable (dicts, lists, strings, numbers, etc.) so they can be persisted across requests. For non-serializable values like HTTP clients or database connections, pass `serializable=False`:
```python
@mcp.tool
async def my_tool(ctx: Context) -> str:
# This object can't be JSON-serialized
client = SomeHTTPClient(base_url="https://api.example.com")
await ctx.set_state("client", client, serializable=False)
# Retrieve it later in the same request
client = await ctx.get_state("client")
return await client.fetch("/data")
```
Values stored with `serializable=False` only live for the current MCP request (a single tool call, resource read, or prompt render). They will not be available in subsequent requests within the session.
#### Custom Storage Backends
By default, session state uses an in-memory store suitable for single-server deployments. For distributed or serverless deployments, provide a custom storage backend:
```python
from key_value.aio.stores.redis import RedisStore
# Use Redis for distributed state
mcp = FastMCP("distributed-app", session_state_store=RedisStore(...))
```
Any backend compatible with the [py-key-value-aio](https://github.com/strawgate/py-key-value) `AsyncKeyValue` protocol works. See [Storage Backends](/servers/storage-backends) for more options including Redis, DynamoDB, and MongoDB.
#### State and Mounted Servers
Each `FastMCP` instance has its own session state store. When you `mount()` a child server, state set on the parent is not visible to tools on the child, and vice versa:
The common case is a middleware that resolves something once and every tool reads it, rather than each tool recomputing it:
```python
from fastmcp import FastMCP, Context
from fastmcp.server.middleware import Middleware, MiddlewareContext
parent = FastMCP("Parent")
child = FastMCP("Child")
parent.mount(child, namespace="child")
mcp = FastMCP("app")
class Stasher(Middleware):
class Enrich(Middleware):
async def on_call_tool(self, context: MiddlewareContext, call_next):
await context.fastmcp_context.set_state("user", "alice")
await context.fastmcp_context.set_state("caller", "alice")
return await call_next(context)
parent.add_middleware(Stasher())
@child.tool
mcp.add_middleware(Enrich())
@mcp.tool
async def whoami(ctx: Context) -> str:
return await ctx.get_state("user") or "unknown" # returns "unknown"
return await ctx.get_state("caller") or "unknown"
```
To share state across the mount boundary, pass the same store to both servers:
The state is scoped to the one request and discarded when it returns. State is also inherited by mounted children, so a value a parent middleware sets is visible to a mounted server's tools within the same request.
**Method signatures:**
- **`await ctx.set_state(key, value, *, serializable=True)`** — store a value
- **`await ctx.get_state(key)`** — retrieve a value (returns `None` if not set)
- **`await ctx.delete_state(key)`** — remove a value
#### Non-serializable resources
The most useful thing request state holds is objects you *can't* persist — a database connection or an HTTP client that a middleware or the [lifespan](/servers/lifespan) opens and a handler uses. Pass `serializable=False`:
```python
from key_value.aio.stores.memory import MemoryStore
@mcp.tool
async def my_tool(ctx: Context) -> str:
client = SomeHTTPClient(base_url="https://api.example.com")
await ctx.set_state("client", client, serializable=False)
store = MemoryStore()
parent = FastMCP("Parent", session_state_store=store)
child = FastMCP("Child", session_state_store=store)
parent.mount(child, namespace="child")
client = await ctx.get_state("client")
return await client.fetch("/data")
```
Alternatively, state set with `serializable=False` lives on the request context and is inherited by mounted children automatically — use it when the value is request-scoped and does not need to persist across tool calls.
A `serializable=False` value lives on the request context for the current call only. It is inherently request-scoped — a live connection can't be serialized and stored — which is exactly why it belongs here rather than in a persistent store.
#### State During Initialization
#### Persisting across requests
State set during `on_initialize` middleware persists to subsequent tool calls when using the same session object (STDIO, SSE, single-server HTTP). For distributed/serverless HTTP deployments where different machines handle init and tool calls, state is isolated by the `mcp-session-id` header.
Request state does not survive from one call to the next. When you need a cart, a conversation, or any state that outlives a single request, use [Session State](/servers/sessions) — it stores server-side, keyed by the authenticated user, and works on every protocol era. (On session-based, handshake-era connections, serializable request state also persists across the session, but Session State is the deliberate, cross-era way to do it.)
### Session Visibility

107
docs/servers/sessions.mdx Normal file
View file

@ -0,0 +1,107 @@
---
title: Session State
sidebarTitle: Sessions
description: Persist state across requests on stateless connections.
icon: id-badge
---
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="4.0.0" />
The modern MCP protocol (`2026-07-28`) is stateless. Every request stands alone: the server builds a fresh connection to handle it and discards everything when it returns. There is no session to hang state on, so a tool that wants to remember something between calls — the items in a cart, the thread of a conversation, a running total — has nowhere to keep it. Store it on the connection and it vanishes the moment the request finishes.
This is a deliberate choice in the protocol. Weighing protocol-level sessions against statelessness, the MCP working group [chose statelessness](https://github.com/modelcontextprotocol/transports-wg/blob/main/docs/sessions-vs-sessionless-decision.md) and moved session semantics up to the application: the server hands the client an identifier, and the client passes it back as an argument on later calls. Their own example is a shopping cart — the server returns a `basket_id`, and the client includes it in each subsequent `add_item` and `checkout` call.
FastMCP implements that pattern as **session state**, and adds the one thing the bare handle lacks: isolation. State is stored server-side and keyed to the authenticated user, so a handle is inert in anyone else's hands. You pick one of two shapes per tool, depending on whether a user has a single bucket of state or many.
## Per-user state
Most tools that remember things want one bucket per user — their preferences, their history, their accumulated context. Declare a `UserSession` parameter and FastMCP injects it, keyed to the authenticated user. It behaves like the request [context](/servers/context): it never appears in the tool's input schema and the caller passes nothing, because the user's identity comes from their validated credentials and selects the right bucket automatically.
```python
from fastmcp import FastMCP
from fastmcp.server.sessions import UserSession
mcp = FastMCP("assistant")
@mcp.tool
async def remember(fact: str, session: UserSession) -> str:
facts = await session.get("facts", default=[])
facts.append(fact)
await session.set("facts", facts)
return f"Remembered {len(facts)} facts."
```
Because the bucket is chosen from the caller's identity, `UserSession` requires [authentication](/servers/auth/authentication). On an unauthenticated request there is no user to key on, so the tool raises a clear error rather than guessing at a bucket.
## Distinct sessions
Sometimes one user needs more than one bucket — separate carts, parallel conversations, independent workflows. Now the caller has to say *which* session it means, so the identifier becomes a tool argument.
Declare a `SessionId` parameter. Unlike `UserSession`, it appears in the input schema as a string, because the agent is the one that supplies it. FastMCP fills in that argument's description for you — instructing the agent to obtain an id and pass it back — so the tool teaches the protocol on its own, with no prompting on your side.
An agent obtains an id by calling `create_session`, which comes from a `SessionProvider` — [providers](/servers/providers/overview) are how FastMCP contributes functionality like this. Register one whenever your tools take a `session_id`. Without it there is no way to mint an id, so every id is rejected and the tools cannot resolve a session — a mistake you catch the first time you run them.
```python
from fastmcp import FastMCP
from fastmcp.server.sessions import SessionId, SessionProvider
from fastmcp.server.dependencies import get_session
mcp = FastMCP("shop")
mcp.add_provider(SessionProvider())
@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 in cart."
```
`get_session` resolves and validates the id, returning a [`Session`](#the-session-object). It is a standalone function, not a context method, so it needs no foreground context and works from a [background task](/servers/tasks)'s worker as well as a normal request.
A session id is real and owned: `create_session` records it under the current user, and only an id created that way resolves. Passing an id that was never created — or one created by a different user — raises rather than quietly opening a fresh bucket, so a typo or a stolen id fails loudly instead of misrouting state.
When your application already mints its own identifiers — conversation ids, workflow ids — take them as ordinary string arguments rather than `SessionId`, and skip the provider entirely; `SessionId` is specifically the create-then-pass contract backed by `create_session`.
## The session object
Both patterns give a tool a `Session`: an async view over one bucket of stored state. Read a value with `await session.get(key, default=None)`, write one with `await session.set(key, value)`, and remove one with `await session.delete(key)`. Values are stored as JSON, so anything JSON-serializable round-trips.
The session's own identifier is available as `session.id` — the id for a session resolved from a `session_id` argument, and `None` for an injected `UserSession`, which has no distinct id because its bucket is the authenticated user.
`await session.clear()` empties the session's state while keeping the session itself valid — the id still resolves, the bucket is just empty. To retire a session entirely, an agent calls `end_session`, which deletes it so the id no longer resolves at all.
## Isolation
Every session is keyed by two things, in this order: the authenticated user, then the session id. The order is the whole security model. The user is the wall; the id only organizes sessions *within* that wall.
On an authenticated request the user comes from the validated token, which the caller cannot forge. Two different users can pass the very same session id and never reach each other's data, because each id is namespaced under its user's identity. This makes a session id safe to expose — it travels through the agent's context and your logs, and on its own it grants nothing. Guessing another user's id leads nowhere: it was created under *their* namespace, so in the guesser's namespace it simply does not exist and the call is rejected.
Without authentication there is no user to key on, and the guarantee changes.
**An unauthenticated session is a bearer handle: whoever holds the id can read and write it.** Ids from `create_session` are unguessable, which keeps a caller from stumbling onto another session, but that is guess-resistance, not isolation — a leaked id is a leaked session. Treat unauthenticated sessions as single-tenant: sound for a personal server with one trusted client, never a boundary between tenants. Multi-tenant isolation requires authentication.
## Storage and lifetime
Session state lives in the server's [storage backend](/servers/storage-backends) — in-memory by default, or Redis or another shared store when a fleet of servers must see the same sessions. Because the store owns retention, it owns expiry: FastMCP writes session state without a TTL of its own, so the store you configure is the single place session data lives and expires. To give every session a default lifetime, wrap the store so writes without an explicit TTL get one — for example, the `key-value` library's TTL-clamp wrapper takes a `missing_ttl`:
```python
from key_value.aio.stores.redis import RedisStore
from key_value.aio.wrappers.ttl_clamp import TTLClampWrapper
store = RedisStore(url="redis://localhost:6379")
store = TTLClampWrapper(store, min_ttl=0, max_ttl=86400, missing_ttl=3600)
mcp = FastMCP("shop", session_state_store=store)
```
Now a session expires an hour after its last write, and `end_session` still removes one immediately.
## Relationship to request state
The request [context](/servers/context) also carries state, through `ctx.set_state` and `ctx.get_state`, and the two solve different problems. Context state is scoped to a single request — the right place for a value that a middleware sets and a handler reads within the same call. Session state is what persists *across* requests. When you need a value to survive from one tool call to the next, reach for `UserSession` or `SessionId`; when it only needs to live for the current request, keep it on the context.

View file

@ -638,8 +638,9 @@ class Client(
"""Get the result of the initialization request.
`None` on a modern (`server/discover`) connection, which negotiates via a
`DiscoverResult` rather than an `InitializeResult`. Use `protocol_version` /
`server_capabilities` for era-neutral access to the negotiated identity.
`DiscoverResult` rather than an `InitializeResult`. Use `protocol_version`,
`server_info`, `server_capabilities`, and `instructions` for era-neutral
access to the negotiated server metadata.
"""
return self._session_state.initialize_result
@ -663,6 +664,27 @@ class Client(
session = self._session_state.session
return session.server_capabilities if session is not None else None
@property
def server_info(self) -> mcp_types.Implementation | None:
"""The session's server identity, or `None` when disconnected.
Populated from whichever negotiation result the era produced (the
`InitializeResult` on legacy, the `DiscoverResult` on modern). A directly
pinned modern version uses a synthesized identity with an empty name.
"""
session = self._session_state.session
return session.server_info if session is not None else None
@property
def instructions(self) -> str | None:
"""The server's instructions, or `None` when absent or disconnected.
Populated from whichever negotiation result the era produced (the
`InitializeResult` on legacy, the `DiscoverResult` on modern).
"""
session = self._session_state.session
return session.instructions if session is not None else None
def set_roots(self, roots: RootsList | RootsHandler) -> None:
"""Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
self._session_kwargs["list_roots_callback"] = create_roots_callback(roots)
@ -840,8 +862,9 @@ class Client(
With `mode="auto"` or a pinned modern version, connect-time negotiation may adopt
the modern `server/discover` era, which has no `InitializeResult`; in that case
this method raises. Read `protocol_version` / `server_capabilities` instead, or use
`mode="legacy"` when you need the handshake result.
this method raises. Read `protocol_version`, `server_info`,
`server_capabilities`, and `instructions` instead, or use `mode="legacy"`
when you need the handshake result.
Args:
timeout: Optional timeout for the initialization request (seconds or timedelta).
@ -874,8 +897,9 @@ class Client(
if self.initialize_result is None:
raise RuntimeError(
"The client negotiated a modern protocol era (server/discover), which has "
"no InitializeResult. Read client.protocol_version / client.server_capabilities "
"instead, or construct the client with mode='legacy'."
"no InitializeResult. Inspect client.protocol_version, client.server_info, "
"client.server_capabilities, and client.instructions for the metadata "
"available in this mode, or construct the client with mode='legacy'."
)
return self.initialize_result

View file

@ -919,6 +919,17 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
value=proxy_client,
)
# The SDK's RegistrationHandler serializes this same `client_info` object
# into the DCR response after we return. Left untouched it would echo the
# SDK's default `client_secret_post` (or a requested `client_secret_basic`)
# and a generated secret — a confidential method the proxy never enforces
# and does not advertise in server metadata. Normalize it to the public
# client we actually store so the registration response, stored client, and
# advertised `token_endpoint_auth_methods_supported` all agree.
client_info.token_endpoint_auth_method = "none"
client_info.client_secret = None
client_info.client_secret_expires_at = None
# Log redirect URIs to help users discover what patterns they might need
if client_info.redirect_uris:
for uri in client_info.redirect_uris:
@ -2312,26 +2323,18 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
# always be overridden to advertise support — not just when
# CIMD or identity assertion is also enabled.
metadata.authorization_response_iss_parameter_supported = True
# Every client the proxy authenticates at the token endpoint is
# public: DCR-registered and synthesized clients are stored with
# `token_endpoint_auth_method="none"`, and CIMD clients use
# `private_key_jwt`. The SDK's default advertisement of
# `client_secret_basic`/`client_secret_post` is misleading — the
# proxy never enforces a downstream client secret — so we override
# it to reflect the methods actually supported.
auth_methods = ["none"]
if self._cimd_manager is not None:
metadata.client_id_metadata_document_supported = True
existing = metadata.token_endpoint_auth_methods_supported or []
metadata.token_endpoint_auth_methods_supported = [
*existing,
"private_key_jwt",
"none",
]
if self._identity_assertion is not None:
# DCR clients are public (`token_endpoint_auth_method="none"`),
# so a metadata consumer must see `none` advertised to use the
# jwt-bearer grant — even when CIMD (which also adds it) is off.
methods_supported = (
metadata.token_endpoint_auth_methods_supported or []
)
if "none" not in methods_supported:
metadata.token_endpoint_auth_methods_supported = [
*methods_supported,
"none",
]
auth_methods.append("private_key_jwt")
metadata.token_endpoint_auth_methods_supported = auth_methods
handler = MetadataHandler(metadata)
methods = route.methods or ["GET", "OPTIONS"]

View file

@ -11,6 +11,7 @@ from logging import Logger
from typing import Any, Literal, cast, overload
import mcp_types
from key_value.aio.errors import SerializationError
from mcp import LoggingLevel, ServerSession
from mcp.server.context import ServerRequestContext
from mcp_types import (
@ -27,7 +28,10 @@ from typing_extensions import TypeVar
from uncalled_for import SharedContext
import fastmcp
from fastmcp.exceptions import FastMCPDeprecationWarning, ToolError
from fastmcp.exceptions import (
FastMCPDeprecationWarning,
ToolError,
)
from fastmcp.resources.base import ResourceResult
from fastmcp.server.dependencies import FastMCPRequestContext, fastmcp_request_ctx
from fastmcp.server.elicitation import (
@ -1437,10 +1441,10 @@ class Context:
value=StateValue(value=value),
ttl=self._STATE_TTL_SECONDS,
)
except Exception as e:
# Catch serialization errors from Pydantic (ValueError) or
# the key_value library (SerializationError). Both contain
# "serialize" in the message. Other exceptions propagate as-is.
except (ValueError, SerializationError) as e:
# Pydantic raises PydanticSerializationError (a ValueError) and the
# key_value library raises SerializationError; both carry "serialize"
# in the message. Other ValueErrors propagate unchanged.
if "serialize" in str(e).lower():
raise TypeError(
f"Value for state key {key!r} is not serializable. "

View file

@ -40,11 +40,15 @@ from fastmcp.utilities.async_utils import (
call_sync_fn_in_threadpool,
is_coroutine_function,
)
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type
if TYPE_CHECKING:
from fastmcp.server.context import Context
from fastmcp.server.server import FastMCP
from fastmcp.server.sessions import Session
logger = get_logger(__name__)
@dataclass
@ -154,6 +158,7 @@ __all__ = [
"get_http_headers",
"get_http_request",
"get_server",
"get_session",
"is_docket_available",
"resolve_dependencies",
"transform_context_annotations",
@ -259,10 +264,11 @@ def is_docket_available() -> bool:
def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Transform ctx: Context into ctx: Context = CurrentContext().
"""Transform injected-by-type params into Dependency-defaulted params.
Transforms ALL params typed as Context to use Docket's DI system,
unless they already have a Dependency-based default (like CurrentContext()).
Transforms ALL params typed as Context (into ``= CurrentContext()``) and as
UserSession (into ``= CurrentSession()``) to use Docket's DI system, unless
they already have a Dependency-based default.
This unifies the legacy type annotation DI with Docket's Depends() system,
allowing both patterns to work through a single resolution path.
@ -278,6 +284,7 @@ def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]:
Function with modified signature (same function object, updated __signature__)
"""
from fastmcp.server.context import Context
from fastmcp.server.sessions import UserSession
# Get the function's signature
try:
@ -294,13 +301,28 @@ def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]:
# First pass: identify which params need transformation
params_to_transform: set[str] = set()
optional_context_params: set[str] = set()
session_params: set[str] = set()
optional_session_params: set[str] = set()
for name, param in sig.parameters.items():
annotation = type_hints.get(name, param.annotation)
if isinstance(param.default, Dependency):
continue
if is_class_member_of_type(annotation, Context):
if not isinstance(param.default, Dependency):
params_to_transform.add(name)
if param.default is None:
optional_context_params.add(name)
params_to_transform.add(name)
if param.default is None:
optional_context_params.add(name)
elif is_class_member_of_type(annotation, UserSession):
# `session: UserSession` rides the same DI path as `ctx: Context`:
# injected per authenticated principal, excluded from the schema. A
# bare `session: Session` is NOT injected — only the `UserSession`
# marker keys the per-user injection.
params_to_transform.add(name)
# A `UserSession | None = None` param opts into the unauthenticated
# case: inject `None` instead of raising, mirroring optional Context.
if param.default is None:
optional_session_params.add(name)
else:
session_params.add(name)
if not params_to_transform:
return fn
@ -321,12 +343,20 @@ def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]:
var_keyword: list[P] = [] # **kwargs (at most one)
for name, param in sig.parameters.items():
# Transform Context params by adding CurrentContext default
# Transform injected-by-type params by adding a Dependency default
if name in params_to_transform:
# We use CurrentContext() instead of Depends(get_context) because
# get_context() returns the Context which is an AsyncContextManager,
# and the DI system would try to enter it again (it's already entered)
if name in optional_context_params:
if name in session_params:
from fastmcp.server.sessions import CurrentSession
param = param.replace(default=CurrentSession())
elif name in optional_session_params:
from fastmcp.server.sessions import OptionalCurrentSession
param = param.replace(default=OptionalCurrentSession())
elif name in optional_context_params:
param = param.replace(default=OptionalCurrentContext())
else:
param = param.replace(default=CurrentContext())
@ -437,6 +467,41 @@ def get_server() -> FastMCP:
return server
async def get_session(session_id: str) -> Session:
"""Resolve and validate a `Session` for an explicit `session_id`.
Pair with a `session_id: SessionId` tool argument (the agent obtains an id
from `create_session` and passes it back). For a single per-user bucket with
nothing for the agent to pass, inject `session: UserSession` instead.
State is keyed by `(principal, session_id)`: the authenticated principal is
the isolation wall and `session_id` organizes sessions within it. The id must
have been minted by `create_session` under the current principal; an id that
was never created, or created under a different principal, raises
`InvalidSession` rather than resolving to a fresh empty bucket (the specific
reason is logged at debug level, never returned to the caller).
Like `get_server()`, this resolves through the task-aware server, so it needs
no foreground context it works from a `task=True` tool's Docket worker as
well as a normal request.
"""
from fastmcp.server.sessions import InvalidSession, Session, current_principal
session = Session(
store=get_server()._state_store,
principal=current_principal(),
session_id=session_id,
public_id=session_id,
)
if not await session._exists():
logger.debug(
"Rejected session id %r: no record for the current principal.",
session_id,
)
raise InvalidSession
return session
def get_http_request() -> Request:
"""Get the current HTTP request.

View file

@ -0,0 +1,537 @@
"""Stateless session state: server-side per-user and per-session storage.
Modern (2026-07-28) MCP connections are stateless by construction every
request builds a fresh connection whose in-memory state is discarded when the
request returns. This module gives tools two explicit ways to keep state across
calls, both backed by the server's existing state store and both isolated by the
authenticated principal rather than by any client-declared identifier.
- `Session`: async `get`/`set`/`delete`/`clear` over a single dict stored under
one key, scoped to a `(principal, session_id)` pair. This is the state-accessor
object a handler works with the value the standalone `get_session(id)`
returns and the value injected for a `UserSession` parameter.
- `session: UserSession` (injected): a per-user bucket, dependency-injected like
`ctx: Context` and keyed by the request's authenticated principal. Requires
auth. `UserSession` is the injection annotation; the injected value is a
`Session`. It is always available under auth no `create_session`, no
provider, no validation.
- `session_id: SessionId` (argument): a required string the agent supplies,
resolved with the standalone `await get_session(session_id)`. The id is
minted
by `create_session`; an id that was never created (or was created under a
different principal) is rejected. This validation is the whole guarantee an
unminted id never resolves, so nothing enforces provider registration.
- `SessionProvider`: a `Provider` contributing `create_session` / `end_session`
tools. Register it with `mcp.add_provider(SessionProvider())` so a tool that
takes `session_id` has a way to mint ids; without it, no id can be created, so
those tools simply cannot resolve a session.
Isolation is the authenticated principal, not the session id. State keyed by
`(principal, session_id)` means a request under principal B can never address
principal A's keys, no matter what `session_id` it passes; the id only organizes
sessions within a principal. Without auth there is no principal wall a session
id is a bearer capability and sessions are not a boundary between clients.
"""
from __future__ import annotations
import functools
import hashlib
import inspect
import json
import time
from collections.abc import Callable, Sequence
from functools import lru_cache
from types import TracebackType
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Final,
cast,
get_args,
get_origin,
get_type_hints,
)
from uuid import uuid4
from mcp.server.auth.provider import principal_components
from uncalled_for import Dependency
from fastmcp.exceptions import FastMCPError
from fastmcp.server.dependencies import get_access_token, get_server, get_session
from fastmcp.server.providers.base import Provider
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from key_value.aio.adapters.pydantic import PydanticAdapter
from fastmcp.server.server import StateValue
from fastmcp.tools.base import Tool
logger = get_logger(__name__)
# The description the framework auto-populates onto a `SessionId` argument so an
# agent reading the tool schema learns the create-then-pass contract with no
# hand-prompting.
# Deliberately names no specific tool. The session-creation tool can be renamed
# by composition — mounting a server under a namespace exposes it as, e.g.,
# `child_create_session` — so hard-coding a tool name here would point agents at
# a tool that does not exist under that mount. Describing the capability keeps
# the contract correct regardless of how the lifecycle tool is named.
SESSION_ID_DESCRIPTION: Final[str] = (
"Session identifier. Use a tool to create a session, then pass the resulting "
"id here to persist state across calls in the same session."
)
# Reserved top-level keys in a session's stored dict. User state lives under
# `_STATE_KEY` (a sub-dict), and `_MARKER_KEY` records that the session was
# created. Keeping user state in a sub-dict means normal `set`/`delete`/`clear`
# can never collide with or clobber the creation marker, so a created session
# stays distinguishable from a missing one — including after `clear()`, which
# empties the sub-dict but leaves the marker in place.
_MARKER_KEY: Final[str] = "_created"
_STATE_KEY: Final[str] = "state"
# Fixed session-id suffix for the injected per-user bucket. The principal is
# already hashed into the key's namespace segment (`_principal_segment`), which
# alone makes the bucket unique per user — using the *raw* principal again as
# the id suffix would embed unhashed identity data (issuer, client id, subject)
# in the storage key and in any logs that record it. A reserved constant avoids
# that while a `create_session`-minted uuid4 can never collide with it.
_USER_SESSION_ID: Final[str] = "_user"
class SessionAuthError(FastMCPError):
"""An injected `session: UserSession` was requested with no authenticated principal.
Per-user session injection keys off the request's authenticated principal, so
it is only meaningful under auth. A tool that needs cross-call state without
auth should take a `session_id: SessionId` argument instead.
"""
def __init__(
self,
message: str = (
"Injected `session: UserSession` requires an authenticated principal, "
"but this request is unauthenticated. Use a `session_id: SessionId` "
"argument for cross-call state on unauthenticated connections."
),
) -> None:
super().__init__(message)
class InvalidSession(FastMCPError):
"""A session id did not resolve to a session created under the current principal.
Raised by `get_session(session_id)` when the id was never created, or was
created under a different principal. The public message is deliberately
generic the specific reason (which id, which principal) is logged at debug
level, not returned to the caller, so an attacker cannot distinguish "unknown
id" from "belongs to someone else".
"""
def __init__(self, message: str = "Invalid or unknown session.") -> None:
super().__init__(message)
def current_principal() -> str | None:
"""The authenticated principal for the current request as a compact JSON string.
Returns the `(client_id, issuer, subject)` triple encoded as compact JSON, or
`None` on an unauthenticated request. Two users of one OAuth client are
distinct principals whenever the token verifier supplies a subject.
"""
token = get_access_token()
if token is None:
return None
return json.dumps(principal_components(token), separators=(",", ":"))
def _principal_segment(principal: str | None) -> str:
"""A fixed-length, delimiter-safe key segment for a principal.
Hashing keeps an arbitrary principal string from injecting the `:` key
delimiter and bounds the key length. `None` (unauthenticated) collapses to a
single shared `anon` segment without a principal there is no isolation wall.
"""
if principal is None:
return "anon"
return hashlib.sha256(principal.encode("utf-8", "surrogatepass")).hexdigest()
def session_storage_key(principal: str | None, session_id: str) -> str:
"""The single storage key holding a session's state dict.
Keyed by `(principal, session_id)`: the principal is the isolation wall, the
id organizes sessions within it. A session's whole state lives under this one
key as a dict, so one key means one store TTL per session and `end` is a
single delete.
"""
return f"session:{_principal_segment(principal)}:{session_id}"
class Session:
"""Async accessors over one `(principal, session_id)` bucket of state.
A session's state is a single dict stored under one key. That dict holds user
state in a `state` sub-dict and a small creation marker alongside it, so a
created-but-empty session is still distinguishable from a missing one.
`get`/`set`/`delete` read-modify-write the sub-dict; `clear` empties the
sub-dict but keeps the session valid; `end` deletes the whole key. Writes
never impose a TTL retention is entirely the server store's (configure it on
the store you pass to `FastMCP(session_state_store=...)`).
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.
"""
def __init__(
self,
*,
store: PydanticAdapter[StateValue],
principal: str | None,
session_id: str,
public_id: str | None = None,
) -> None:
self._store = store
self._principal = principal
self._session_id = session_id
self._public_id = public_id
self._key = session_storage_key(principal, session_id)
@property
def id(self) -> str | None:
"""The session's identifier, or `None` for an injected per-user session.
For a session resolved from a `session_id` argument (or minted by
`create_session`) this is that id. An injected `UserSession` has no
distinct id its bucket is the authenticated user so it is `None`; the
internal principal-derived key is deliberately not exposed here.
"""
return self._public_id
async def _load_raw(self) -> dict[str, Any] | None:
"""Read the session's full stored dict, or `None` when the key is unset."""
result = await self._store.get(key=self._key)
if result is None:
return None
value = result.value
return dict(value) if isinstance(value, dict) else None
async def _save_raw(self, data: dict[str, Any]) -> None:
"""Write the session's full dict back under its single key (no TTL)."""
from fastmcp.server.server import StateValue
await self._store.put(key=self._key, value=StateValue(value=data))
@staticmethod
def _state_of(raw: dict[str, Any] | None) -> dict[str, Any]:
"""The user-state sub-dict of a raw stored dict (empty when absent)."""
if raw is None:
return {}
state = raw.get(_STATE_KEY)
return dict(state) if isinstance(state, dict) else {}
async def _exists(self) -> bool:
"""Whether a session record exists for this `(principal, session_id)`.
True only once `create_session` has written the creation marker. A raw
store entry without the marker (e.g. an injected `UserSession` bucket) is
not a created session and does not satisfy this check.
"""
raw = await self._load_raw()
return raw is not None and _MARKER_KEY in raw
async def _create(self) -> None:
"""Write the initial record so the session exists (called by `create_session`)."""
raw = await self._load_raw() or {}
raw[_MARKER_KEY] = time.time()
raw.setdefault(_STATE_KEY, {})
await self._save_raw(raw)
async def get(self, key: str, default: Any = None) -> Any:
"""Return the value for `key`, or `default` when it is not set."""
raw = await self._load_raw()
return self._state_of(raw).get(key, default)
async def set(self, key: str, value: Any) -> None:
"""Store `value` under `key` in this session (read-modify-write).
Preserves the creation marker: only the user-state sub-dict is touched.
"""
raw = await self._load_raw() or {}
state = self._state_of(raw)
state[key] = value
raw[_STATE_KEY] = state
await self._save_raw(raw)
async def delete(self, key: str) -> None:
"""Remove `key` from this session, if present (preserves the marker)."""
raw = await self._load_raw()
if raw is None:
return
state = self._state_of(raw)
if key in state:
del state[key]
raw[_STATE_KEY] = state
await self._save_raw(raw)
async def clear(self) -> None:
"""Empty the session's user state but keep the session valid.
The user-state sub-dict is reset to empty while the creation marker stays
in place, so a cleared session still resolves through `get_session`.
To invalidate a session entirely, use `end` (what `end_session` calls).
"""
raw = await self._load_raw()
if raw is None:
return
raw[_STATE_KEY] = {}
await self._save_raw(raw)
async def end(self) -> None:
"""Invalidate the session — delete its one key and all of its state.
After this the id no longer resolves through `get_session`. This is
what `end_session` calls; `clear` only empties state and keeps the session.
"""
await self._store.delete(key=self._key)
class UserSession(Session):
"""Annotation marker for the injected per-user session.
A `session: UserSession` parameter is **dependency-injected** like
`ctx: Context`: keyed by the request's authenticated principal, excluded from
the input schema, and requiring auth (it raises `SessionAuthError` with no
principal). It doubles as the injection *annotation* and the injected
type the value a handler receives is a `UserSession`, which subclasses
`Session`, so `await session.get(...)`, `.set`, `.delete`, and `.clear` all
work exactly as on any other `Session`.
Unlike `session_id: SessionId`, the per-user bucket needs no `create_session`,
no `SessionProvider`, and no validation it is always available under auth,
keyed directly by the caller's identity.
```python
from fastmcp.server.sessions import UserSession
@mcp.tool
async def remember(fact: str, session: UserSession) -> str:
await session.set("fact", fact)
return "noted"
```
Subclasses `Session` only so the framework's type-based injection detector can
key off it; it adds no behavior of its own.
"""
class _SessionIdMarker:
"""Metadata marker identifying a `SessionId`-annotated parameter."""
# A `session_id: SessionId` parameter is a plain required string in the input
# schema (the agent supplies it); the marker lets the framework recognize it and
# auto-populate its description with the create-then-pass contract.
SessionId = Annotated[str, _SessionIdMarker()]
@lru_cache(maxsize=5000)
def session_id_parameter_names(fn: Callable[..., object]) -> tuple[str, ...]:
"""Names of a function's parameters annotated with `SessionId`.
Scans resolved type hints for `Annotated[str, _SessionIdMarker()]` metadata.
Returns an empty tuple when the hints cannot be resolved (the function then
simply carries no auto-populated session-id description).
`functools.partial` is unwrapped first, since `get_type_hints` rejects a
partial object FastMCP supports registering a partial as a tool, and its
schema is still built from the underlying function, so its `SessionId`
parameters must be detected here too. Parameters the partial has already
bound positionally or by keyword are dropped, matching the tool's actual
argument surface (the partial's own signature already reflects this).
"""
target: object = fn
while isinstance(target, functools.partial):
target = target.func
if not callable(target):
return ()
try:
hints = get_type_hints(target, include_extras=True)
except (TypeError, NameError):
return ()
# `inspect.signature` on the (possibly partial) callable reports only the
# parameters still open to callers — a partial's bound positional and keyword
# arguments are already removed — so it is the source of truth for the tool's
# argument surface. Fall back to accepting every hinted name if the signature
# cannot be read.
try:
remaining = set(inspect.signature(fn).parameters)
except (TypeError, ValueError):
remaining = None
names: list[str] = []
for name, hint in hints.items():
if name == "return" or (remaining is not None and name not in remaining):
continue
if get_origin(hint) is not Annotated:
continue
if any(isinstance(meta, _SessionIdMarker) for meta in get_args(hint)[1:]):
names.append(name)
return tuple(names)
def _current_user_session() -> UserSession | None:
"""Build the per-user session for the current principal, or `None` if unauth.
Resolves the store through `get_server()` rather than `get_context()`: a
`task=True` tool whose only injected dependency is `UserSession` runs in a
Docket worker with no foreground context, and `get_server()` is task-aware (it
resolves via the task-server map in a worker).
"""
principal = current_principal()
if principal is None:
return None
return UserSession(
store=get_server()._state_store,
principal=principal,
session_id=_USER_SESSION_ID,
)
class _CurrentSession(Dependency["Session"]):
"""Dependency that injects a per-user `Session` keyed by the request principal.
Mirrors `_CurrentContext`: a `session: UserSession` parameter is rewritten to
default to this dependency, so it is excluded from the input schema and
resolved at call time. Raises `SessionAuthError` when the request carries no
authenticated principal.
"""
async def __aenter__(self) -> Session:
session = _current_user_session()
if session is None:
raise SessionAuthError
return session
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
return None
class _OptionalCurrentSession(Dependency["Session | None"]):
"""Dependency for an *optional* per-user session (`session: UserSession | None`).
Mirrors `_OptionalCurrentContext`: when the request carries no authenticated
principal it injects `None` instead of raising, so a handler that declares the
parameter optional (default `None`) can run on unauthenticated requests and
branch on whether a session is available.
"""
async def __aenter__(self) -> Session | None:
return _current_user_session()
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
return None
def CurrentSession() -> Session:
"""Inject the per-user `Session` for the current authenticated principal.
Rarely written explicitly a `session: UserSession` parameter is rewritten
to this. Provided for parity with `CurrentContext()` when an explicit default
is preferred.
"""
return cast("Session", _CurrentSession())
def OptionalCurrentSession() -> Session | None:
"""Inject the per-user `Session`, or `None` when the request is unauthenticated.
Rarely written explicitly a `session: UserSession | None = None` parameter
is rewritten to this. Provided for parity with `OptionalCurrentContext()`.
"""
return cast("Session | None", _OptionalCurrentSession())
async def create_session() -> str:
"""Create a new session and return its identifier.
Mints an unguessable `uuid4`, records an initial session owned by the current
principal, and returns the id as a string. Store it and pass it back as a
`session_id` argument on later calls to persist state across a session only
an id created this way resolves. State is keyed by the authenticated
principal, so the id organizes sessions within a user; on an unauthenticated
connection the id is the only thing standing between callers, which is why it
is unguessable.
"""
session_id = str(uuid4())
session = Session(
store=get_server()._state_store,
principal=current_principal(),
session_id=session_id,
public_id=session_id,
)
await session._create()
return session_id
async def end_session(session_id: SessionId) -> str:
"""End a session and delete all of its state.
Validates the id like any other resolution (an unknown or foreign id is
rejected), then deletes the session's key so the id no longer resolves.
"""
session = await get_session(session_id)
await session.end()
return "session ended"
class SessionProvider(Provider):
"""Provider contributing the session lifecycle tools.
Register it whenever a tool declares a `session_id: SessionId` argument:
```python
from fastmcp.server.sessions import SessionProvider
mcp.add_provider(SessionProvider())
```
It registers two tools:
- `create_session()` mints an unguessable `uuid4`, records the session, and
returns the id.
- `end_session(session_id)` invalidates that session and deletes its state.
It owns no storage (session state lives in the server's configured
`session_state_store`) and imposes no TTL (retention is the store's). It
exists to mint and end owned session ids. Registration is not enforced: with
no provider, no id can be created, so every `get_session(...)` rejects
a `session_id` tool without a provider simply cannot resolve a session.
"""
def __init__(self) -> None:
super().__init__()
self._tools: list[Tool] | None = None
async def _list_tools(self) -> Sequence[Tool]:
if self._tools is None:
from fastmcp.tools.base import Tool
self._tools = [
Tool.from_function(create_session),
Tool.from_function(end_session),
]
return self._tools

View file

@ -20,7 +20,7 @@ from mcp_types import (
ToolExecution,
)
from mcp_types import Tool as MCPTool
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field, PrivateAttr, model_validator
from pydantic.json_schema import SkipJsonSchema
from fastmcp.utilities.authorization import AuthCheck
@ -83,6 +83,8 @@ def default_serializer(data: Any) -> str:
class ToolResult(BaseModel):
_raw_mcp_result: CallToolResult | None = PrivateAttr(default=None)
content: list[ContentBlock] = Field(
description="List of content blocks for the tool result"
)
@ -148,11 +150,26 @@ class ToolResult(BaseModel):
is_error=is_error,
)
@classmethod
def from_mcp_result(cls, result: CallToolResult) -> ToolResult:
"""Wrap a protocol result while preserving its exact wire representation."""
tool_result = cls(
content=result.content,
structured_content=result.structured_content,
meta=result.meta,
is_error=result.is_error,
)
tool_result._raw_mcp_result = result
return tool_result
def to_mcp_result(
self,
) -> (
list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult
):
if self._raw_mcp_result is not None:
return self._raw_mcp_result
# An error result must round-trip through CallToolResult so isError
# reaches the client; the plain content/tuple returns can't carry it.
if self.meta is not None or self.is_error:
@ -342,6 +359,9 @@ class Tool(FastMCPComponent):
if isinstance(raw_value, ToolResult):
return raw_value
if isinstance(raw_value, CallToolResult):
return ToolResult.from_mcp_result(raw_value)
if _HAS_PREFAB:
if isinstance(raw_value, _PrefabApp):
return _prefab_to_tool_result(

View file

@ -349,6 +349,26 @@ class ParsedFunction:
):
properties[param_name]["description"] = param_desc
# Auto-populate the create-then-pass contract onto `SessionId`-annotated
# parameters so an agent learns it straight from the schema. Append to any
# author-provided description rather than clobbering it.
from fastmcp.server.sessions import (
SESSION_ID_DESCRIPTION,
session_id_parameter_names,
)
properties = input_schema.get("properties", {})
for param_name in session_id_parameter_names(fn):
if param_name not in properties:
continue
existing = properties[param_name].get("description")
if not existing:
properties[param_name]["description"] = SESSION_ID_DESCRIPTION
elif SESSION_ID_DESCRIPTION not in existing:
properties[param_name]["description"] = (
f"{existing}\n\n{SESSION_ID_DESCRIPTION}"
)
output_schema = None
# Get the return annotation from the signature
sig = inspect.signature(fn)
@ -393,6 +413,13 @@ class ParsedFunction:
if is_class_member_of_type(output_type, ToolResult):
output_type = _UnserializableType
# A bare CallToolResult gives the tool full protocol-level control
# over its response, so there is no FastMCP output schema to infer.
if isinstance(output_type, type) and issubclass(
output_type, mcp_types.CallToolResult
):
output_type = _UnserializableType
# If InputRequiredResult survives stripping in any wrapping — bare,
# via a `type X = ...` alias, Annotated, or a subclass — it is a
# guard-only return with no output data (a union would have had its

View file

@ -387,6 +387,29 @@ class FunctionTool(Tool):
exec_is_async = is_coroutine_function(wrapper_fn)
strict = _strict_input_validation()
result = await self._run_body(
type_adapter, exec_is_async, arguments, strict=strict
)
# An `InputRequiredResult` is the full result of this multi-round-trip
# leg (SEP-2322), not tool-output data: wrap it in an
# `InputRequiredToolResult` so it flows through the middleware chain as
# an ordinary result instead of being serialized as content. The wire
# handler reads it back out (see `_on_call_tool`).
if isinstance(result, mcp_types.InputRequiredResult):
return InputRequiredToolResult(result)
return self.convert_result(result)
async def _run_body(
self,
type_adapter: TypeAdapter[Any],
exec_is_async: bool,
arguments: dict[str, Any],
*,
strict: bool,
) -> Any:
"""Validate arguments and execute the body, applying any timeout."""
try:
if self.timeout is not None:
try:
@ -423,15 +446,7 @@ class FunctionTool(Tool):
assert original is not None
raise original from original.__cause__
# An `InputRequiredResult` is the full result of this multi-round-trip
# leg (SEP-2322), not tool-output data: wrap it in an
# `InputRequiredToolResult` so it flows through the middleware chain as
# an ordinary result instead of being serialized as content. The wire
# handler reads it back out (see `_on_call_tool`).
if isinstance(result, mcp_types.InputRequiredResult):
return InputRequiredToolResult(result)
return self.convert_result(result)
return result
async def _execute(
self,

View file

@ -248,6 +248,9 @@ class TestPinnedMode:
async with Client(fastmcp_server, mode=LATEST_MODERN_VERSION) as client:
assert client.protocol_version == LATEST_MODERN_VERSION
assert client.initialize_result is None
assert client.server_info is not None
assert client.server_info.name == ""
assert client.instructions is None
async def test_pinned_modern_call_tool(self, fastmcp_server):
async with Client(fastmcp_server, mode=LATEST_MODERN_VERSION) as client:
@ -256,10 +259,20 @@ class TestPinnedMode:
class TestConnectionProperties:
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_server_metadata_available_across_eras(self, mode):
server = FastMCP("MetadataServer", instructions="Use the metadata tools.")
async with Client(server, mode=mode) as client:
assert client.server_info is not None
assert client.server_info.name == "MetadataServer"
assert client.instructions == "Use the metadata tools."
async def test_properties_none_before_connect(self, fastmcp_server):
client = Client(fastmcp_server, mode="auto")
assert client.protocol_version is None
assert client.server_capabilities is None
assert client.server_info is None
assert client.instructions is None
async def test_properties_none_after_disconnect(self, fastmcp_server):
client = Client(fastmcp_server, mode="auto")
@ -267,6 +280,8 @@ class TestConnectionProperties:
assert client.protocol_version is not None
assert client.protocol_version is None
assert client.server_capabilities is None
assert client.server_info is None
assert client.instructions is None
class TestManualNegotiation:

View file

@ -197,6 +197,35 @@ class TestOAuthProxyClientRegistration:
assert registered_client is not None
assert registered_client.scope == "read write calendar"
@pytest.mark.parametrize(
"requested_auth_method",
[None, "client_secret_post", "client_secret_basic"],
)
async def test_dcr_response_is_public_client(
self, oauth_proxy, requested_auth_method
):
"""The DCR response must describe the public client the proxy actually
stores never a confidential method / secret the proxy does not enforce
and does not advertise in server metadata.
"""
registration = {"redirect_uris": ["https://client.example.com/callback"]}
if requested_auth_method is not None:
registration["token_endpoint_auth_method"] = requested_auth_method
app = Starlette(routes=oauth_proxy.get_routes())
transport = httpx2.ASGITransport(app=app)
async with httpx2.AsyncClient(
transport=transport,
base_url="https://myserver.com",
) as client:
response = await client.post("/register", json=registration)
assert response.status_code == 201
client_info = response.json()
assert client_info["token_endpoint_auth_method"] == "none"
assert client_info.get("client_secret") is None
class TestUpstreamClientIdFallback:
"""Tests for clients that skip DCR and use the upstream client_id directly."""

View file

@ -235,12 +235,38 @@ class TestOAuthProxyInitialization:
metadata = response.json()
assert metadata.get("client_id_metadata_document_supported") is True
assert set(metadata.get("token_endpoint_auth_methods_supported")) == {
"client_secret_post",
"client_secret_basic",
"private_key_jwt",
"none",
}
async def test_metadata_advertises_only_public_client_auth(self, jwt_verifier):
"""The proxy authenticates every client as public, so metadata must
advertise `none` and must not claim secret-based methods it never enforces.
"""
proxy = OAuthProxy(
upstream_authorization_endpoint="https://auth.example.com/authorize",
upstream_token_endpoint="https://auth.example.com/token",
upstream_client_id="client-123",
upstream_client_secret="secret-456",
token_verifier=jwt_verifier,
base_url="https://api.example.com",
jwt_signing_key="test-secret",
client_storage=MemoryStore(),
enable_cimd=False,
)
app = Starlette(routes=proxy.get_routes())
transport = httpx2.ASGITransport(app=app)
async with httpx2.AsyncClient(
transport=transport, base_url="https://api.example.com"
) as client:
response = await client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200
metadata = response.json()
assert set(metadata.get("token_endpoint_auth_methods_supported")) == {"none"}
async def test_metadata_advertises_authorization_response_issuer_parameter(
self, jwt_verifier
):

View file

@ -0,0 +1,569 @@
"""End-to-end tests for the two session-state patterns and `SessionProvider`.
Covers the injected `session: UserSession` per-user pattern, the explicit
`session_id: SessionId` argument pattern (with its create-then-validate
lifecycle), and the `SessionProvider` that supplies the `create_session` /
`end_session` lifecycle tools. The schema, registration, and lifecycle paths run
through an in-memory `Client`; the principal-isolation cases drive the tool
through its full injection + storage path under a simulated authenticated
principal.
"""
import re
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from uuid import UUID
import pytest
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken as SDKAccessToken
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server.context import Context
from fastmcp.server.dependencies import get_session
from fastmcp.server.sessions import (
SESSION_ID_DESCRIPTION,
InvalidSession,
SessionId,
SessionProvider,
UserSession,
)
from fastmcp.tools.base import ToolResult
def result_value(result: ToolResult) -> Any:
"""The `"result"` field of a direct `Tool.run()` call's structured content.
`structured_content` is `dict | None` on `ToolResult` (a bare function tool
always populates it, but the type isn't narrowed by construction), so this
asserts it is present before indexing.
"""
assert result.structured_content is not None
return result.structured_content["result"]
def make_token(*, subject: str = "user-a") -> SDKAccessToken:
return SDKAccessToken(
token="opaque",
client_id="client-1",
scopes=[],
subject=subject,
claims={"iss": "https://issuer.example"},
)
@contextmanager
def as_principal(token: SDKAccessToken | None) -> Iterator[None]:
if token is None:
yield
return
reset = auth_context_var.set(AuthenticatedUser(token))
try:
yield
finally:
auth_context_var.reset(reset)
def build_injected_server() -> FastMCP:
"""Server whose cart tools inject a per-user `session: UserSession`."""
server = FastMCP("shop")
@server.tool
async def add_to_cart(item: str, session: UserSession) -> int:
cart = await session.get("cart", default=[])
cart.append(item)
await session.set("cart", cart)
return len(cart)
@server.tool
async def view_cart(session: UserSession) -> list[str]:
return await session.get("cart", default=[])
return server
def build_id_server() -> FastMCP:
"""Server whose cart tools take an explicit `session_id: SessionId`."""
server = FastMCP("shop")
server.add_provider(SessionProvider())
@server.tool
async def add_to_cart(item: str, session_id: SessionId) -> int:
session = await get_session(session_id)
cart = await session.get("cart", default=[])
cart.append(item)
await session.set("cart", cart)
return len(cart)
@server.tool
async def view_cart(session_id: SessionId) -> list[str]:
session = await get_session(session_id)
return await session.get("cart", default=[])
return server
# ---------------------------------------------------------------------------
# Injected `session: UserSession`
# ---------------------------------------------------------------------------
class TestInjectedSession:
async def test_not_in_input_schema(self):
server = build_injected_server()
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
schema = tools["add_to_cart"].input_schema
assert "session" not in schema["properties"]
assert "item" in schema["properties"]
async def test_errors_without_auth(self):
server = build_injected_server()
async with Client(server) as client:
with pytest.raises(ToolError):
await client.call_tool("view_cart", {})
async def test_state_survives_across_calls_per_user(self):
server = build_injected_server()
add_tool = await server.get_tool("add_to_cart")
view_tool = await server.get_tool("view_cart")
assert add_tool is not None
assert view_tool is not None
async with Context(fastmcp=server):
with as_principal(make_token(subject="user-a")):
await add_tool.run({"item": "apple"})
second = await add_tool.run({"item": "banana"})
view = await view_tool.run({})
assert result_value(second) == 2
assert result_value(view) == ["apple", "banana"]
async def test_two_principals_get_isolated_buckets(self):
server = build_injected_server()
add_tool = await server.get_tool("add_to_cart")
view_tool = await server.get_tool("view_cart")
assert add_tool is not None
assert view_tool is not None
async with Context(fastmcp=server):
with as_principal(make_token(subject="user-a")):
await add_tool.run({"item": "apple"})
with as_principal(make_token(subject="user-b")):
view_b = await view_tool.run({})
assert result_value(view_b) == []
async def test_injected_value_is_a_user_session_instance(self):
"""The handler receives a `UserSession`, not a bare `Session` — so
`isinstance(session, UserSession)` holds for code that keys off it."""
server = FastMCP("shop")
@server.tool
async def whoami(session: UserSession) -> bool:
return isinstance(session, UserSession)
tool = await server.get_tool("whoami")
assert tool is not None
async with Context(fastmcp=server):
with as_principal(make_token()):
result = await tool.run({})
assert result_value(result) is True
async def test_optional_session_is_none_without_auth(self):
"""`session: UserSession | None = None` injects `None` on an
unauthenticated request instead of raising."""
server = FastMCP("shop")
@server.tool
async def maybe(session: UserSession | None = None) -> bool:
return session is None
tool = await server.get_tool("maybe")
assert tool is not None
async with Context(fastmcp=server):
result = await tool.run({})
assert result_value(result) is True
async def test_optional_session_is_present_with_auth(self):
"""The same optional parameter injects a real `UserSession` when the
request is authenticated."""
server = FastMCP("shop")
@server.tool
async def maybe(session: UserSession | None = None) -> bool:
return isinstance(session, UserSession)
tool = await server.get_tool("maybe")
assert tool is not None
async with Context(fastmcp=server):
with as_principal(make_token()):
result = await tool.run({})
assert result_value(result) is True
async def test_optional_session_not_in_input_schema(self):
"""An optional injected session is still excluded from the schema."""
server = FastMCP("shop")
@server.tool
async def maybe(session: UserSession | None = None) -> bool:
return session is None
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
assert "session" not in tools["maybe"].input_schema.get("properties", {})
async def test_user_session_needs_no_provider(self):
"""A server using only `UserSession` requires no `SessionProvider` and
lists no lifecycle tools."""
server = build_injected_server()
async with Client(server) as client:
names = {t.name for t in await client.list_tools()}
assert "create_session" not in names
assert "end_session" not in names
async def test_storage_key_does_not_embed_the_raw_principal(self):
"""The injected session is stored under the reserved per-user id, not
under the raw principal JSON proven by reconstructing a `Session`
against the reserved id and reading back what injection wrote."""
from fastmcp.server.sessions import (
_USER_SESSION_ID,
Session,
current_principal,
session_storage_key,
)
server = build_injected_server()
add_tool = await server.get_tool("add_to_cart")
assert add_tool is not None
token = make_token(subject="user-a")
async with Context(fastmcp=server):
with as_principal(token):
await add_tool.run({"item": "apple"})
principal = current_principal()
assert principal is not None
assert token.subject is not None
# The raw principal never appears in the storage key itself.
key = session_storage_key(principal, _USER_SESSION_ID)
assert principal not in key
assert token.subject not in key
assert token.client_id not in key
# And the reserved-id reconstruction reads back what injection wrote,
# proving injection actually used `_USER_SESSION_ID` as the session id.
reconstructed = Session(
store=server._state_store,
principal=principal,
session_id=_USER_SESSION_ID,
)
assert await reconstructed.get("cart") == ["apple"]
# ---------------------------------------------------------------------------
# Explicit `session_id: SessionId` — create then validate
# ---------------------------------------------------------------------------
class TestSessionIdArgument:
async def test_session_id_is_a_required_string_with_contract_description(self):
server = build_id_server()
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
schema = tools["view_cart"].input_schema
prop = schema["properties"]["session_id"]
assert prop["type"] == "string"
assert "session_id" in schema["required"]
assert prop["description"] == SESSION_ID_DESCRIPTION
async def test_created_id_round_trips_state_across_calls(self):
server = build_id_server()
async with Client(server) as client:
session_id = (await client.call_tool("create_session", {})).data
await client.call_tool(
"add_to_cart", {"item": "apple", "session_id": session_id}
)
second = await client.call_tool(
"add_to_cart", {"item": "banana", "session_id": session_id}
)
assert second.data == 2
view = await client.call_tool("view_cart", {"session_id": session_id})
assert view.data == ["apple", "banana"]
async def test_resolved_session_exposes_its_id(self):
"""A session resolved from a `session_id` argument carries that id."""
server = FastMCP("shop")
server.add_provider(SessionProvider())
@server.tool
async def which_session(session_id: SessionId) -> str | None:
return (await get_session(session_id)).id
async with Client(server) as client:
session_id = (await client.call_tool("create_session", {})).data
result = (
await client.call_tool("which_session", {"session_id": session_id})
).data
assert result == session_id
async def test_uncreated_id_is_rejected(self):
"""An id that was never handed out by `create_session` does not resolve."""
server = build_id_server()
async with Client(server) as client:
with pytest.raises(ToolError):
await client.call_tool("view_cart", {"session_id": "never-created"})
async def test_distinct_created_ids_are_isolated(self):
server = build_id_server()
async with Client(server) as client:
id_a = (await client.call_tool("create_session", {})).data
id_b = (await client.call_tool("create_session", {})).data
assert id_a != id_b
await client.call_tool("add_to_cart", {"item": "apple", "session_id": id_a})
view_b = await client.call_tool("view_cart", {"session_id": id_b})
assert view_b.data == []
async def test_end_session_invalidates_the_session(self):
"""After `end_session` the id no longer resolves at all."""
server = build_id_server()
async with Client(server) as client:
session_id = (await client.call_tool("create_session", {})).data
await client.call_tool(
"add_to_cart", {"item": "apple", "session_id": session_id}
)
await client.call_tool("end_session", {"session_id": session_id})
with pytest.raises(ToolError):
await client.call_tool("view_cart", {"session_id": session_id})
async def test_clear_keeps_the_session_valid(self):
"""`session.clear()` empties state but the session still resolves."""
server = FastMCP("shop")
server.add_provider(SessionProvider())
@server.tool
async def add_to_cart(item: str, session_id: SessionId) -> int:
session = await get_session(session_id)
cart = await session.get("cart", default=[])
cart.append(item)
await session.set("cart", cart)
return len(cart)
@server.tool
async def clear_cart(session_id: SessionId) -> str:
session = await get_session(session_id)
await session.clear()
return "cleared"
@server.tool
async def view_cart(session_id: SessionId) -> list[str]:
session = await get_session(session_id)
return await session.get("cart", default=[])
async with Client(server) as client:
session_id = (await client.call_tool("create_session", {})).data
await client.call_tool(
"add_to_cart", {"item": "apple", "session_id": session_id}
)
await client.call_tool("clear_cart", {"session_id": session_id})
# Still resolves (no error), and state is empty.
view = await client.call_tool("view_cart", {"session_id": session_id})
assert view.data == []
async def test_created_under_one_principal_rejected_under_another(self):
"""An id created by principal A is rejected when used by principal B."""
server = build_id_server()
create_tool = await server.get_tool("create_session")
view_tool = await server.get_tool("view_cart")
assert create_tool is not None
assert view_tool is not None
async with Context(fastmcp=server):
with as_principal(make_token(subject="user-a")):
created = await create_tool.run({})
session_id = result_value(created)
with as_principal(make_token(subject="user-b")):
with pytest.raises(InvalidSession):
await view_tool.run({"session_id": session_id})
with as_principal(make_token(subject="user-a")):
# A's own session still resolves.
view_a = await view_tool.run({"session_id": session_id})
assert result_value(view_a) == []
async def test_two_principals_same_id_are_isolated(self):
server = build_id_server()
create_tool = await server.get_tool("create_session")
add_tool = await server.get_tool("add_to_cart")
view_tool = await server.get_tool("view_cart")
assert create_tool is not None
assert add_tool is not None
assert view_tool is not None
async with Context(fastmcp=server):
with as_principal(make_token(subject="user-a")):
id_a = result_value(await create_tool.run({}))
await add_tool.run({"item": "apple", "session_id": id_a})
with as_principal(make_token(subject="user-b")):
id_b = result_value(await create_tool.run({}))
# B's own session under its own id is empty.
view_b = await view_tool.run({"session_id": id_b})
assert result_value(view_b) == []
with as_principal(make_token(subject="user-a")):
view_a = await view_tool.run({"session_id": id_a})
assert result_value(view_a) == ["apple"]
# ---------------------------------------------------------------------------
# SessionProvider
# ---------------------------------------------------------------------------
class TestSessionProvider:
async def test_lifecycle_tools_registered_via_add_provider(self):
server = FastMCP("s")
server.add_provider(SessionProvider())
async with Client(server) as client:
names = {t.name for t in await client.list_tools()}
assert {"create_session", "end_session"} <= names
async def test_create_session_returns_a_uuid_string(self):
server = FastMCP("s")
server.add_provider(SessionProvider())
async with Client(server) as client:
session_id = (await client.call_tool("create_session", {})).data
assert isinstance(session_id, str)
# Parses as a uuid4 and is unguessable (not a fixed/empty value).
assert str(UUID(session_id)) == session_id
async def test_create_session_ids_are_distinct(self):
server = FastMCP("s")
server.add_provider(SessionProvider())
async with Client(server) as client:
first = (await client.call_tool("create_session", {})).data
second = (await client.call_tool("create_session", {})).data
assert first != second
async def test_end_session_declares_session_id_contract(self):
server = FastMCP("s")
server.add_provider(SessionProvider())
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
prop = tools["end_session"].input_schema["properties"]["session_id"]
assert prop["type"] == "string"
assert SESSION_ID_DESCRIPTION in prop["description"]
class TestNoProviderIsNonFatal:
"""With the enforcement checks removed, a `session_id` tool without a
`SessionProvider` is not a setup error it simply cannot resolve a session,
because no id can be created. The failure surfaces at use, not at listing."""
async def test_session_id_tool_lists_without_a_provider(self):
server = FastMCP("shop")
@server.tool
async def add_to_cart(item: str, session_id: SessionId) -> int:
return len(item)
async with Client(server) as client:
names = {t.name for t in await client.list_tools()}
assert names == {"add_to_cart"}
async def test_any_id_is_rejected_without_a_way_to_create_one(self):
server = FastMCP("shop")
@server.tool
async def add_to_cart(item: str, session_id: SessionId) -> str:
session = await get_session(session_id)
await session.set("item", item)
return "ok"
# No provider, so no id was ever minted: resolution rejects any id.
async with Client(server) as client:
with pytest.raises(ToolError):
await client.call_tool(
"add_to_cart", {"item": "x", "session_id": "made-up"}
)
class TestSessionIdDescriptionAppending:
async def test_author_description_is_preserved_and_appended(self):
server = FastMCP("s")
server.add_provider(SessionProvider())
@server.tool
async def resume(session_id: SessionId) -> str:
"""Resume work.
Args:
session_id: The handle for this workflow.
"""
return session_id
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
desc = tools["resume"].input_schema["properties"]["session_id"]["description"]
assert "The handle for this workflow." in desc
assert SESSION_ID_DESCRIPTION in desc
# Author text comes first, contract appended after.
assert re.search(r"handle for this workflow\.\s+Session identifier\.", desc)
async def test_contract_is_not_duplicated_when_author_repeats_it(self):
"""An author who already includes the contract text doesn't get it twice."""
from typing import Annotated
from pydantic import Field
server = FastMCP("s")
server.add_provider(SessionProvider())
@server.tool
async def resume(
session_id: Annotated[SessionId, Field(description=SESSION_ID_DESCRIPTION)],
) -> str:
return session_id
async with Client(server) as client:
tools = {t.name: t for t in await client.list_tools()}
desc = tools["resume"].input_schema["properties"]["session_id"]["description"]
assert desc.count(SESSION_ID_DESCRIPTION) == 1
async def test_description_survives_namespaced_mount(self):
"""The contract names no specific tool, so it stays correct when a mount
renames the lifecycle tool under a namespace the description must not
point agents at an unqualified `create_session` that does not exist
under that mount."""
child = FastMCP("child")
child.add_provider(SessionProvider())
@child.tool
async def workflow(session_id: SessionId) -> str:
return session_id
parent = FastMCP("parent")
parent.mount(child, namespace="child")
async with Client(parent) as client:
tools = {t.name: t for t in await client.list_tools()}
# The lifecycle tool is renamed under the namespace...
assert "child_create_session" in tools
assert "create_session" not in tools
# ...yet the session_id contract still resolves correctly, because it
# describes the capability rather than naming a tool.
desc = tools["child_workflow"].input_schema["properties"]["session_id"][
"description"
]
assert desc == SESSION_ID_DESCRIPTION
assert "create_session" not in desc

View file

@ -0,0 +1,263 @@
"""Unit tests for the stateless session-state primitives.
Covers the principal helpers, the `(principal, session_id)` key scheme, and the
`Session` object's read-modify-write behavior against a real server store.
"""
import functools
import json
from collections.abc import Iterator
from contextlib import contextmanager
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken as SDKAccessToken
from mcp.server.auth.provider import principal_components
from fastmcp.server.server import FastMCP
from fastmcp.server.sessions import (
Session,
SessionId,
current_principal,
session_id_parameter_names,
session_storage_key,
)
def make_token(
*, subject: str = "user-a", client_id: str = "client-1"
) -> SDKAccessToken:
return SDKAccessToken(
token="opaque",
client_id=client_id,
scopes=[],
subject=subject,
claims={"iss": "https://issuer.example"},
)
def principal_string(token: SDKAccessToken) -> str:
return json.dumps(principal_components(token), separators=(",", ":"))
@contextmanager
def as_principal(token: SDKAccessToken | None) -> Iterator[None]:
if token is None:
yield
return
reset = auth_context_var.set(AuthenticatedUser(token))
try:
yield
finally:
auth_context_var.reset(reset)
def make_session(server: FastMCP, principal: str | None, session_id: str) -> Session:
return Session(
store=server._state_store, principal=principal, session_id=session_id
)
class TestPrincipalHelpers:
def test_current_principal_none_without_auth(self):
assert current_principal() is None
def test_current_principal_encodes_triple(self):
token = make_token()
with as_principal(token):
assert current_principal() == principal_string(token)
class TestStorageKey:
def test_principal_is_the_isolation_wall(self):
principal_a = principal_string(make_token(subject="user-a"))
principal_b = principal_string(make_token(subject="user-b"))
# Same session id, different principals -> different keys.
assert session_storage_key(principal_a, "s1") != session_storage_key(
principal_b, "s1"
)
def test_id_organizes_within_a_principal(self):
principal = principal_string(make_token())
assert session_storage_key(principal, "s1") != session_storage_key(
principal, "s2"
)
def test_unauthenticated_collapses_to_shared_namespace(self):
assert session_storage_key(None, "s1").startswith("session:anon:")
def test_principal_not_embedded_verbatim(self):
principal = principal_string(make_token())
# The principal is hashed into a fixed segment, never embedded raw.
assert principal not in session_storage_key(principal, "s1")
class TestSessionRoundTrip:
async def test_set_get_delete(self):
server = FastMCP("test")
session = make_session(server, None, "s1")
assert await session.get("missing") is None
assert await session.get("missing", default=[]) == []
await session.set("cart", ["apple"])
assert await session.get("cart") == ["apple"]
await session.delete("cart")
assert await session.get("cart") is None
async def test_multiple_keys_share_one_dict(self):
server = FastMCP("test")
session = make_session(server, None, "s1")
await session.set("a", 1)
await session.set("b", 2)
assert await session.get("a") == 1
assert await session.get("b") == 2
async def test_clear_removes_everything(self):
server = FastMCP("test")
session = make_session(server, None, "s1")
await session.set("a", 1)
await session.set("b", 2)
await session.clear()
assert await session.get("a") is None
assert await session.get("b") is None
async def test_delete_missing_key_is_a_noop(self):
server = FastMCP("test")
session = make_session(server, None, "s1")
await session.delete("nope") # does not raise
assert await session.get("nope") is None
class TestSessionIdProperty:
def test_id_is_none_without_a_public_id(self):
# An injected `UserSession` is built this way — no distinct public id.
server = FastMCP("test")
assert make_session(server, None, "s1").id is None
def test_id_returns_the_public_id(self):
server = FastMCP("test")
session = Session(
store=server._state_store,
principal=None,
session_id="s1",
public_id="s1",
)
assert session.id == "s1"
class TestSessionIsolation:
async def test_distinct_ids_are_isolated(self):
server = FastMCP("test")
principal = principal_string(make_token())
await make_session(server, principal, "s1").set("cart", ["apple"])
assert await make_session(server, principal, "s2").get("cart") is None
async def test_same_id_different_principals_are_isolated(self):
server = FastMCP("test")
principal_a = principal_string(make_token(subject="user-a"))
principal_b = principal_string(make_token(subject="user-b"))
await make_session(server, principal_a, "shared-id").set("cart", ["a-item"])
# B passes the *same* session id but reaches its own empty bucket.
assert await make_session(server, principal_b, "shared-id").get("cart") is None
# A still sees its own data.
assert await make_session(server, principal_a, "shared-id").get("cart") == [
"a-item"
]
class TestSharedStore:
async def test_sessions_share_the_one_server_store(self):
"""A second Session for the same key sees the first's writes."""
server = FastMCP("test")
await make_session(server, None, "s1").set("x", 42)
# A freshly constructed handle for the same (principal, id) reads it back.
assert await make_session(server, None, "s1").get("x") == 42
class TestFalsyValues:
async def test_stored_falsy_value_is_not_treated_as_missing(self):
server = FastMCP("test")
session = make_session(server, None, "s1")
await session.set("count", 0)
await session.set("flag", False)
assert await session.get("count", default=99) == 0
assert await session.get("flag", default=True) is False
class TestLifecycleMarker:
async def test_uncreated_session_does_not_exist(self):
server = FastMCP("test")
session = make_session(server, None, "s1")
assert await session._exists() is False
async def test_created_session_exists(self):
server = FastMCP("test")
session = make_session(server, None, "s1")
await session._create()
assert await session._exists() is True
async def test_writing_state_does_not_clobber_the_marker(self):
server = FastMCP("test")
session = make_session(server, None, "s1")
await session._create()
# A user key literally named like the marker cannot collide with it,
# because user state lives in a namespaced sub-dict.
await session.set("_created", "user-value")
await session.set("cart", ["apple"])
await session.delete("cart")
assert await session._exists() is True
assert await session.get("_created") == "user-value"
async def test_clear_keeps_the_session_but_empties_state(self):
server = FastMCP("test")
session = make_session(server, None, "s1")
await session._create()
await session.set("cart", ["apple"])
await session.clear()
assert await session._exists() is True
assert await session.get("cart") is None
async def test_end_removes_the_session_entirely(self):
server = FastMCP("test")
session = make_session(server, None, "s1")
await session._create()
await session.set("cart", ["apple"])
await session.end()
assert await session._exists() is False
assert await session.get("cart") is None
class TestSessionIdParameterNames:
def test_detects_plain_parameter(self):
def tool(item: str, session_id: SessionId) -> None: ...
assert session_id_parameter_names(tool) == ("session_id",)
def test_none_when_absent(self):
def tool(item: str) -> None: ...
assert session_id_parameter_names(tool) == ()
def test_partial_positional_binding_is_dropped(self):
# A positionally bound leading argument is no longer part of the tool's
# argument surface; the `session_id` that remains is still detected.
def tool(item: str, session_id: SessionId) -> None: ...
bound = functools.partial(tool, "apple")
assert session_id_parameter_names(bound) == ("session_id",)
def test_partial_binding_the_session_id_positionally_drops_it(self):
def tool(session_id: SessionId, item: str) -> None: ...
bound = functools.partial(tool, "s1")
assert session_id_parameter_names(bound) == ()
def test_partial_keyword_binding_stays_detected(self):
# A keyword-bound partial argument remains overridable by the caller, so
# it is still in the tool's input schema — detection tracks the schema
# and keeps populating its description.
def tool(item: str, session_id: SessionId) -> None: ...
bound = functools.partial(tool, session_id="s1")
assert session_id_parameter_names(bound) == ("session_id",)

View file

@ -3,7 +3,13 @@ from typing import Annotated, Any
import pytest
from inline_snapshot import snapshot
from mcp_types import AudioContent, EmbeddedResource, ImageContent, TextContent
from mcp_types import (
AudioContent,
CallToolResult,
EmbeddedResource,
ImageContent,
TextContent,
)
from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
from typing_extensions import TypedDict
@ -130,6 +136,13 @@ class TestToolFromFunctionOutputSchema:
tool = Tool.from_function(func)
assert tool.output_schema is None
async def test_call_tool_result_return_annotation_no_output_schema(self):
def func() -> CallToolResult:
return CallToolResult(content=[])
tool = Tool.from_function(func)
assert tool.output_schema is None
async def test_tool_result_subclass_return_annotation_no_output_schema(self):
class MyToolResult(ToolResult):
def __init__(self, data: str):

View file

@ -123,6 +123,38 @@ class TestToolResultIsError:
assert result.is_error is True
assert result.content[0].text == "upstream boom"
def test_raw_call_tool_result_is_preserved(self):
tool = Tool.from_function(lambda: None, name="test_tool")
raw_result = CallToolResult(
content=[TextContent(type="text", text="upstream boom")],
structured_content={"code": 42},
is_error=True,
_meta={"source": "upstream"},
)
result = tool.convert_result(raw_result)
assert result.to_mcp_result() is raw_result
async def test_raw_call_tool_result_preserves_protocol_fields(self):
mcp = FastMCP()
raw_result = CallToolResult(
content=[TextContent(type="text", text="upstream boom")],
structured_content={"code": 42},
is_error=True,
_meta={"source": "upstream"},
)
@mcp.tool
def failing() -> CallToolResult:
return raw_result
async with Client(mcp) as client:
result = await client.call_tool_mcp("failing", {})
assert result.model_dump(by_alias=True) == raw_result.model_dump(by_alias=True)
class TestUnionReturnTypes:
"""Tests for tools with union return types."""