mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
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:
commit
3f746b91fc
20 changed files with 2058 additions and 135 deletions
|
|
@ -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>
|
||||
|
|
|
|||
217
docs/development/v4-notes/stateless-session-state.md
Normal file
217
docs/development/v4-notes/stateless-session-state.md
Normal 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`.
|
||||
|
|
@ -160,6 +160,7 @@
|
|||
"servers/dependency-injection",
|
||||
"servers/lifespan",
|
||||
"servers/storage-backends",
|
||||
"servers/sessions",
|
||||
"servers/tasks",
|
||||
"servers/versioning"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
107
docs/servers/sessions.mdx
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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. "
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
537
fastmcp_slim/fastmcp/server/sessions.py
Normal file
537
fastmcp_slim/fastmcp/server/sessions.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
569
tests/server/test_session_provider.py
Normal file
569
tests/server/test_session_provider.py
Normal 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
|
||||
263
tests/server/test_sessions.py
Normal file
263
tests/server/test_sessions.py
Normal 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",)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue