-
Notifications
You must be signed in to change notification settings - Fork 2.1k
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 4 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 |
|---|---|---|
|
|
@@ -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> | ||
|
|
||
| @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. | ||
| For STDIO and SSE transports, state set during `on_initialize` middleware persists to subsequent tool calls (same session object). For StreamableHTTP, state during initialization is isolated because the session ID comes from request headers which aren't available during init. | ||
|
|
||
| ### Change Notifications | ||
|
|
||
|
|
||
| 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()) | ||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,88 @@ | ||||||
| """Client for testing persistent state over STDIO. | ||||||
|
|
||||||
| Run directly: | ||||||
| uv run python examples/persistent_state/client_stdio.py | ||||||
| """ | ||||||
|
|
||||||
| import asyncio | ||||||
| import sys | ||||||
| from pathlib import Path | ||||||
|
|
||||||
| from rich.console import Console | ||||||
|
|
||||||
| from fastmcp import Client, FastMCP | ||||||
|
|
||||||
| # Add parent directory to path for importing the server module | ||||||
| examples_dir: Path = Path(__file__).parent.parent.parent | ||||||
| if str(examples_dir) not in sys.path: | ||||||
| sys.path.insert(0, str(examples_dir)) | ||||||
|
|
||||||
| import examples.persistent_state.server as server_module # noqa: E402 | ||||||
|
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. Remove the unused Ruff flags this directive as unused; remove it (or enable E402 if you intend to keep it). ✅ Proposed fix-import examples.persistent_state.server as server_module # noqa: E402
+import examples.persistent_state.server as server_module📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.14.11)20-20: Unused Remove unused (RUF100) |
||||||
|
|
||||||
| server: FastMCP = server_module.server | ||||||
|
|
||||||
| console: Console = Console() | ||||||
|
|
||||||
|
|
||||||
| 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]") | ||||||
|
|
||||||
| async with Client(server) 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]") | ||||||
|
|
||||||
| await alice.call_tool("get_value", {"key": "user"}) | ||||||
| console.print(" get [white]user[/white] → [green]Alice[/green]") | ||||||
|
|
||||||
| 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]") | ||||||
|
|
||||||
| async with Client(server) 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]") | ||||||
|
|
||||||
| async with Client(server) 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()) | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| """Example: Persistent session-scoped state. | ||
|
|
||
| This demonstrates using Context.get_state() and set_state() to store | ||
| data that persists across tool calls within the same MCP session. | ||
|
|
||
| Run with: | ||
| uv run python examples/persistent_state/server.py | ||
| """ | ||
|
|
||
| from fastmcp import FastMCP | ||
| from fastmcp.server.context import Context | ||
|
|
||
| server = FastMCP("StateExample") | ||
|
|
||
|
|
||
| @server.tool | ||
| async def set_value(key: str, value: str, ctx: Context) -> str: | ||
| """Store a value in session state.""" | ||
| await ctx.set_state(key, value) | ||
| return f"Stored '{key}' = '{value}'" | ||
|
|
||
|
|
||
| @server.tool | ||
| async def get_value(key: str, ctx: Context) -> str: | ||
| """Retrieve a value from session state.""" | ||
| value = await ctx.get_state(key) | ||
| if value is None: | ||
| return f"Key '{key}' not found" | ||
| return f"'{key}' = '{value}'" | ||
|
|
||
|
|
||
| @server.tool | ||
| async def list_session_info(ctx: Context) -> dict[str, str | None]: | ||
| """Get information about the current session.""" | ||
| return { | ||
| "session_id": ctx.session_id, | ||
| "transport": ctx.transport, | ||
| } | ||
|
Comment on lines
+32
to
+38
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. Provide a precise return type for Use a TypedDict (or a typed dict) to meet the “full type annotations” requirement. 🛠️ Proposed fix+from typing import TypedDict
+
+class SessionInfo(TypedDict):
+ session_id: str
+ transport: str | None
+
`@server.tool`
-async def list_session_info(ctx: Context) -> dict:
+async def list_session_info(ctx: Context) -> SessionInfo:
"""Get information about the current session."""
return {
"session_id": ctx.session_id,
"transport": ctx.transport,
} |
||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| server.run(transport="streamable-http") | ||
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.
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)
+Expected results:
+- First call to
increment_counterreturns1.+- Second call to
increment_counterreturns2.