-
Notifications
You must be signed in to change notification settings - Fork 2k
Add session-scoped state persistence #2873
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b1e29a8
e5c5d7f
72f3113
17d5265
cb4cd0d
4b11ad7
eb76cac
b494d23
6ef6c74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
Comment on lines
+153
to
+179
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: RedisStore constructor (py-key-value-aio) — signatures and parameters Signatures:
Parameters:
Source: py-key-value API docs (RedisStore init). [1] [1] py-key-value API Reference — RedisStore init. Expand the session-state examples to be complete and runnable with realistic RedisStore configuration. The code snippets use |
||
| Documentation: `docs/servers/context.mdx` | ||
|
|
||
| --- | ||
|
|
||
| ## Visibility System | ||
|
|
||
| Components can be dynamically enabled/disabled at runtime using the visibility system (`src/fastmcp/server/transforms/visibility.py`). | ||
|
|
@@ -537,3 +568,19 @@ See `docs/development/v3-notes/auth-provider-env-vars.mdx` for rationale. | |
| `FASTMCP_SHOW_CLI_BANNER` → `FASTMCP_SHOW_SERVER_BANNER` | ||
|
|
||
| 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") | ||
| ``` | ||
|
Comment on lines
+572
to
+584
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clarify or link to a full runnable example for the async state migration snippet. This before/after block is helpful, but it isn’t runnable in isolation. Consider adding a short note that it’s illustrative and link to a complete example (with imports, setup, and error handling) for copy-paste use. As per coding guidelines, ... |
||
|
|
||
| State now persists across requests within a session. See "Session-Scoped State" above. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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> | ||
| mcp = FastMCP("stateful-app") | ||
|
|
||
| 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: | ||
| @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 | ||
|
|
||
| ```python {7-8, 16-17} | ||
| from fastmcp.server.middleware import Middleware, MiddlewareContext | ||
| @mcp.tool | ||
| async def get_counter(ctx: Context) -> int: | ||
| """Get the current counter value.""" | ||
| return await ctx.get_state("counter") or 0 | ||
| ``` | ||
|
|
||
| class UserAuthMiddleware(Middleware): | ||
| async def on_call_tool(self, context: MiddlewareContext, call_next): | ||
| Each client session has its own isolated state—two different clients calling `increment_counter` will each have their own counter. | ||
|
|
||
| # Middleware stores user info in context state | ||
| context.fastmcp_context.set_state("user_id", "user_123") | ||
| context.fastmcp_context.set_state("permissions", ["read", "write"]) | ||
| **Method signatures:** | ||
| - **`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 | ||
|
|
||
| return await call_next(context) | ||
| <Note> | ||
| State methods are async and require `await`. State expires after 1 day to prevent unbounded memory growth. | ||
| </Note> | ||
|
Comment on lines
+212
to
+245
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make the Session State section comply with doc standards (runnable + error handling + expected output). The new example doesn’t show error handling or expected results and the prose isn’t consistently second person. Please make it a runnable example (or clearly indicate how to run it) with error handling and expected outcomes. As per coding guidelines, update this section to meet the MDX documentation requirements. ✅ Proposed fix (partial)-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.
+You can store data that persists across multiple requests within the same MCP session. FastMCP automatically keys session state by the client's session to isolate clients.
```python
+ # examples/session_state.py
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
+ try:
+ count = await ctx.get_state("counter") or 0
+ await ctx.set_state("counter", count + 1)
+ return count + 1
+ except Exception as exc:
+ await ctx.error(f"Failed to update counter: {exc}")
+ raise
`@mcp.tool`
async def get_counter(ctx: Context) -> int:
"""Get the current counter value."""
return await ctx.get_state("counter") or 0+Expected results: |
||
|
|
||
| @mcp.tool | ||
| async def secure_operation(data: str, ctx: Context) -> str: | ||
| """Tool can access state set by middleware.""" | ||
| #### Custom Storage Backends | ||
|
|
||
| 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}" | ||
| 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(...)) | ||
| ``` | ||
|
Comment on lines
+247
to
256
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Short answer — py-key-value's RedisStore (async library: py-key-value-aio) accepts either a Redis client or connection info (URL / host / port / password / client kwargs) when you initialize it. Example usages:
Key parameters (per the library docs):
If you want, I can open the specific RedisStore source file and show the exact init signature and supported kwargs. Citations:
🌐 Web query:
💡 Result: Constructor signature (keyword-only) and parameters for RedisStore:
Sources: LangChain RedisStore API/docs. [1] (api.python.langchain.com) Citations: 🏁 Script executed: # Check if there are any existing RedisStore usages or tests in the repository
rg -i "redisstore" --type py -A 3 -B 1Repository: jlowin/fastmcp Length of output: 505 Replace the RedisStore placeholder with a runnable, verified example. The current code at line 255 uses Based on the py-key-value library API, RedisStore accepts a
Example pattern (reference: from key_value.aio.stores.redis import RedisStore
# Use Redis for distributed state
redis_backend = RedisStore(url="redis://localhost:6379")
mcp = FastMCP("distributed-app", session_state_store=redis_backend)Also document prerequisites, connection error handling, and any configuration needed (e.g., Redis server address, authentication). |
||
|
|
||
| **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) | ||
| 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 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 | ||
| #### State During Initialization | ||
|
|
||
| This makes state management predictable and prevents unexpected side effects between nested operations. | ||
| 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 | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| # Persistent Session State | ||
|
|
||
| This example demonstrates session-scoped state that persists across tool calls within the same MCP session. | ||
|
|
||
| ## What it shows | ||
|
|
||
| - State set in one tool call is readable in subsequent calls | ||
| - Different clients have isolated state (same keys, different values) | ||
| - Reconnecting creates a new session with fresh state | ||
|
|
||
| ## Running | ||
|
|
||
| **HTTP transport:** | ||
|
|
||
| ```bash | ||
| # Terminal 1: Start the server | ||
| uv run python server.py | ||
|
|
||
| # Terminal 2: Run the client | ||
| uv run python client.py | ||
| ``` | ||
|
|
||
| **STDIO transport (in-process):** | ||
|
|
||
| ```bash | ||
| uv run python client_stdio.py | ||
| ``` | ||
|
|
||
| ## Example output | ||
|
|
||
| ```text | ||
| Each line below is a separate tool call | ||
|
|
||
| Alice connects | ||
| session a9f6eaa3 | ||
| set user = Alice | ||
| set secret = alice-password | ||
| get user → Alice | ||
| get secret → alice-password | ||
|
|
||
| Bob connects (different session) | ||
| session 0c3bffc5 | ||
| get user → not found | ||
| get secret → not found | ||
| set user = Bob | ||
| get user → Bob | ||
|
|
||
| Alice reconnects (new session) | ||
| session e39640e3 | ||
| get user → not found | ||
| ``` |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,85 @@ | ||||||||||
| """Client for testing persistent state. | ||||||||||
|
|
||||||||||
| Run the server first: | ||||||||||
| uv run python examples/persistent_state/server.py | ||||||||||
|
|
||||||||||
| Then run this client: | ||||||||||
| uv run python examples/persistent_state/client.py | ||||||||||
| """ | ||||||||||
|
|
||||||||||
| import asyncio | ||||||||||
|
|
||||||||||
| from rich.console import Console | ||||||||||
|
|
||||||||||
| from fastmcp import Client | ||||||||||
| from fastmcp.client.transports import StreamableHttpTransport | ||||||||||
|
|
||||||||||
| URL = "http://127.0.0.1:8000/mcp" | ||||||||||
| console = Console() | ||||||||||
|
Comment on lines
+17
to
+18
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, locate the file
find . -type f -name "client.py" | grep -E "persistent_state|examples" | head -20Repository: jlowin/fastmcp Length of output: 466 🏁 Script executed: # Check if the file exists and read its content
if [ -f "examples/persistent_state/client.py" ]; then
echo "=== File exists. Reading content with line numbers ===";
cat -n examples/persistent_state/client.py | head -30
else
echo "File not found at examples/persistent_state/client.py"
find . -type f -name "*.py" | grep -i persistent | head -10
fiRepository: jlowin/fastmcp Length of output: 1052 🏁 Script executed: # Check Python version requirement in the project
find . -maxdepth 2 -type f \( -name "pyproject.toml" -o -name "setup.py" -o -name "setup.cfg" -o -name "requirements.txt" \) | xargs grep -l "python" 2>/dev/null || echo "No version markers found in common locations"Repository: jlowin/fastmcp Length of output: 144 Add required type annotations for module globals. Lines 17-18 are missing type annotations for ✅ Proposed fix-URL = "http://127.0.0.1:8000/mcp"
-console = Console()
+URL: str = "http://127.0.0.1:8000/mcp"
+console: Console = Console()📝 Committable suggestion
Suggested change
|
||||||||||
|
|
||||||||||
|
|
||||||||||
| async def main() -> None: | ||||||||||
| console.print() | ||||||||||
| console.print("[dim italic]Each line below is a separate tool call[/dim italic]") | ||||||||||
| console.print() | ||||||||||
|
|
||||||||||
| # --- Alice's session --- | ||||||||||
| console.print("[dim]Alice connects[/dim]") | ||||||||||
|
|
||||||||||
| transport1 = StreamableHttpTransport(url=URL) | ||||||||||
| async with Client(transport=transport1) as alice: | ||||||||||
| result = await alice.call_tool("list_session_info", {}) | ||||||||||
| console.print(f" session [cyan]{result.data['session_id'][:8]}[/cyan]") | ||||||||||
|
|
||||||||||
| await alice.call_tool("set_value", {"key": "user", "value": "Alice"}) | ||||||||||
| console.print(" set [white]user[/white] = [green]Alice[/green]") | ||||||||||
|
|
||||||||||
| await alice.call_tool("set_value", {"key": "secret", "value": "alice-password"}) | ||||||||||
| console.print(" set [white]secret[/white] = [green]alice-password[/green]") | ||||||||||
|
|
||||||||||
| result = await alice.call_tool("get_value", {"key": "user"}) | ||||||||||
| console.print(" get [white]user[/white] → [green]Alice[/green]") | ||||||||||
|
|
||||||||||
| result = await alice.call_tool("get_value", {"key": "secret"}) | ||||||||||
| console.print(" get [white]secret[/white] → [green]alice-password[/green]") | ||||||||||
|
|
||||||||||
| console.print() | ||||||||||
|
|
||||||||||
| # --- Bob's session --- | ||||||||||
| console.print("[dim]Bob connects (different session)[/dim]") | ||||||||||
|
|
||||||||||
| transport2 = StreamableHttpTransport(url=URL) | ||||||||||
| async with Client(transport=transport2) as bob: | ||||||||||
| result = await bob.call_tool("list_session_info", {}) | ||||||||||
| console.print(f" session [cyan]{result.data['session_id'][:8]}[/cyan]") | ||||||||||
|
|
||||||||||
| await bob.call_tool("get_value", {"key": "user"}) | ||||||||||
| console.print(" get [white]user[/white] → [dim]not found[/dim]") | ||||||||||
|
|
||||||||||
| await bob.call_tool("get_value", {"key": "secret"}) | ||||||||||
| console.print(" get [white]secret[/white] → [dim]not found[/dim]") | ||||||||||
|
|
||||||||||
| await bob.call_tool("set_value", {"key": "user", "value": "Bob"}) | ||||||||||
| console.print(" set [white]user[/white] = [green]Bob[/green]") | ||||||||||
|
|
||||||||||
| await bob.call_tool("get_value", {"key": "user"}) | ||||||||||
| console.print(" get [white]user[/white] → [green]Bob[/green]") | ||||||||||
|
|
||||||||||
| console.print() | ||||||||||
|
|
||||||||||
| # --- Alice reconnects --- | ||||||||||
| console.print("[dim]Alice reconnects (new session)[/dim]") | ||||||||||
|
|
||||||||||
| transport3 = StreamableHttpTransport(url=URL) | ||||||||||
| async with Client(transport=transport3) as alice_again: | ||||||||||
| result = await alice_again.call_tool("list_session_info", {}) | ||||||||||
| console.print(f" session [cyan]{result.data['session_id'][:8]}[/cyan]") | ||||||||||
|
|
||||||||||
| await alice_again.call_tool("get_value", {"key": "user"}) | ||||||||||
| console.print(" get [white]user[/white] → [dim]not found[/dim]") | ||||||||||
|
|
||||||||||
| console.print() | ||||||||||
|
|
||||||||||
|
|
||||||||||
| if __name__ == "__main__": | ||||||||||
| asyncio.run(main()) | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
py-key-value-aio RedisStore documentation💡 Result:
You can find the RedisStore docs and usage in the py-key-value-aio project repository (README + stores documentation). Key points:
Sources:
[1] strawgate/py-key-value (GitHub) — README + stores documentation.
[2] py-key-value-aio on PyPI.
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 144
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 85
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 94
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 695
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 1111
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 40
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 1397
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 226
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 690
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 725
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 84
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 144
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 40
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 7766
🏁 Script executed:
Repository: jlowin/fastmcp
Length of output: 1728
Include complete RedisStore configuration with URL parameter and error handling in the migration example.
The RedisStore example is incomplete—it shows
RedisStore(...)without the requiredurlparameter. Per MDX documentation guidelines, add realistic configuration (e.g.,url="redis://localhost:6379"), necessary imports, connection error handling, and an expected outcome. Include thectx.delete_state(key)usage shown in the changelog:Also demonstrate
ctx.delete_state(key)in context to complete the migration picture.