Add session-scoped state persistence (#2873)

This commit is contained in:
Jeremiah Lowin 2026-01-16 14:11:21 -05:00 committed by GitHub
commit c8c84ff911
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 744 additions and 167 deletions

View file

@ -274,6 +274,50 @@ The environment variable for controlling the server banner has been renamed:
This change reflects that the setting now applies to all server startup methods, not just the CLI. The banner is now suppressed when running `python server.py` directly, not just when using `fastmcp run`.
### Context State Methods Are Async
<Warning>
**Breaking Change:** `ctx.set_state()` and `ctx.get_state()` are now async methods. Synchronous calls will fail.
</Warning>
Context state has changed from request-scoped to session-scoped, persisting across multiple tool calls within the same MCP session. The methods are now async because they interact with a pluggable storage backend.
<CodeGroup>
```python Before
@mcp.tool
def my_tool(ctx: Context) -> str:
ctx.set_state("key", "value")
value = ctx.get_state("key")
return value
```
```python After
@mcp.tool
async def my_tool(ctx: Context) -> str:
await ctx.set_state("key", "value")
value = await ctx.get_state("key")
return value
```
</CodeGroup>
**What changed:**
- State now persists across requests within a session (not just within a single request)
- Different clients have isolated state (keyed by session ID)
- State expires after 1 day to prevent unbounded memory growth
- New method: `await ctx.delete_state(key)`
**Custom storage backends:**
By default, state uses an in-memory store. For distributed deployments, provide a custom backend:
```python
from key_value.aio.stores.redis import RedisStore
mcp = FastMCP("server", session_state_store=RedisStore(...))
```
See [Session State](/servers/context#session-state) for full documentation.
## v2.14.0
### OpenAPI Parser Promotion

View file

@ -150,6 +150,37 @@ Documentation: `docs/servers/providers/transforms.mdx`, `docs/servers/visibility
---
## Session-Scoped State
v3.0 changes context state from request-scoped to session-scoped. State now persists across multiple tool calls within the same MCP session.
```python
@mcp.tool
async def increment_counter(ctx: Context) -> int:
count = await ctx.get_state("counter") or 0
await ctx.set_state("counter", count + 1)
return count + 1
```
State is automatically keyed by session ID, ensuring isolation between different clients. The implementation uses [pykeyvalue](https://github.com/strawgate/py-key-value) for pluggable storage backends:
```python
from key_value.aio.stores.redis import RedisStore
# Use Redis for distributed deployments
mcp = FastMCP("server", session_state_store=RedisStore(...))
```
**Key details:**
- Methods are now async: `await ctx.get_state()`, `await ctx.set_state()`, `await ctx.delete_state()`
- State expires after 1 day (TTL) to prevent unbounded memory growth
- Works during `on_initialize` middleware when using the same session object
- For distributed HTTP, session identity comes from the `mcp-session-id` header
Documentation: `docs/servers/context.mdx`
---
## Visibility System
Components can be dynamically enabled/disabled at runtime using the visibility system ([#2708](https://github.com/jlowin/fastmcp/pull/2708)).
@ -727,3 +758,19 @@ See `docs/development/v3-notes/auth-provider-env-vars.mdx` for rationale.
`FASTMCP_SHOW_CLI_BANNER` → `FASTMCP_SHOW_SERVER_BANNER` ([#2771](https://github.com/jlowin/fastmcp/pull/2771))
Now applies to all server startup methods, not just the CLI.
### Context State Methods Are Async
`ctx.set_state()` and `ctx.get_state()` are now async and session-scoped:
```python
# v2.x
ctx.set_state("key", "value")
value = ctx.get_state("key")
# v3.0
await ctx.set_state("key", "value")
value = await ctx.get_state("key")
```
State now persists across requests within a session. See "Session-Scoped State" above.

View file

@ -22,7 +22,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
- **State Management**: Store and share data between middleware and the handler within a single request
- **Session State**: Store data that persists across requests within an MCP session
- **Request Information**: Access metadata about the current request
- **Server Access**: When needed, access the underlying FastMCP server instance
@ -209,55 +209,57 @@ 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
### State Management
### Session State
<VersionBadge version="2.11.0" />
<VersionBadge version="3.0.0" />
Store and share data between middleware and handlers within a single MCP request. Each MCP request (such as calling a tool, reading a resource, listing tools, or listing resources) receives its own context object with isolated state. Context state is particularly useful for passing information from [middleware](/servers/middleware) to your handlers.
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.
To store a value in the context state, use `ctx.set_state(key, value)`. To retrieve a value, use `ctx.get_state(key)`.
```python
from fastmcp import FastMCP, Context
<Warning>
Context state is scoped to a single MCP request. Each operation (tool call, resource read, list operation, etc.) receives a new context object. State set during one request will not be available in subsequent requests. For persistent data storage across requests, use external storage mechanisms like databases, files, or in-memory caches.
</Warning>
This simplified example shows how to use MCP middleware to store user info in the context state, and how to access that state in a tool:
```python {7-8, 16-17}
from fastmcp.server.middleware import Middleware, MiddlewareContext
class UserAuthMiddleware(Middleware):
async def on_call_tool(self, context: MiddlewareContext, call_next):
# Middleware stores user info in context state
context.fastmcp_context.set_state("user_id", "user_123")
context.fastmcp_context.set_state("permissions", ["read", "write"])
return await call_next(context)
mcp = FastMCP("stateful-app")
@mcp.tool
async def secure_operation(data: str, ctx: Context) -> str:
"""Tool can access state set by middleware."""
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
user_id = ctx.get_state("user_id") # "user_123"
permissions = ctx.get_state("permissions") # ["read", "write"]
if "write" not in permissions:
return "Access denied"
return f"Processing {data} for user {user_id}"
@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:**
- **`ctx.set_state(key: str, value: Any) -> None`**: Store a value in the context state
- **`ctx.get_state(key: str) -> Any`**: Retrieve a value from the context state (returns None if not found)
- **`await ctx.set_state(key: str, value: Any) -> None`**: Store a value in session state
- **`await ctx.get_state(key: str) -> Any`**: Retrieve a value (returns None if not found)
- **`await ctx.delete_state(key: str) -> None`**: Remove a value from session state
**State Inheritance:**
When a new context is created (nested contexts), it inherits a copy of its parent's state. This ensures that:
- State set on a child context never affects the parent context
- State set on a parent context after the child context is initialized is not propagated to the child context
<Note>
State methods are async and require `await`. State expires after 1 day to prevent unbounded memory growth.
</Note>
This makes state management predictable and prevents unexpected side effects between nested operations.
#### 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 During Initialization
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.
### Change Notifications
@ -345,7 +347,7 @@ async def request_info(ctx: Context) -> dict:
- **`ctx.request_id -> str`**: Get the unique ID for the current MCP request
- **`ctx.client_id -> str | None`**: Get the ID of the client making the request, if provided during initialization
- **`ctx.session_id -> str | None`**: Get the MCP session ID for session-based data sharing (HTTP transports only)
- **`ctx.session_id -> str`**: Get the MCP session ID for session-based data sharing. Raises `RuntimeError` if the MCP session is not yet established.
#### Request Context Availability