Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 37 additions & 35 deletions docs/servers/context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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:
+- First call to increment_counter returns 1.
+- Second call to increment_counter returns 2.

</details>

<!-- fingerprinting:phantom:poseidon:eagle -->

<!-- This is an auto-generated comment by CodeRabbit -->


@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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

py-key-value RedisStore initialization documentation

💡 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:

  • With redis URL:
    from key_value.aio.stores.redis import RedisStore
    store = RedisStore(url="redis://localhost:6379/0"). (github.com)

  • With host/port (common in docs / FastMCP examples):
    from key_value.aio.stores.redis import RedisStore
    store = RedisStore(host="redis.example.com", port=6379, password="secret"). (fastmcp.mintlify.app)

Key parameters (per the library docs):

  • client: an existing Redis client/connection (optional).
  • redis_url / url: Redis connection URL (optional).
  • client_kwargs: dict of kwargs passed to the Redis client.
  • ttl: default TTL (seconds) applied to keys (optional).
  • namespace: optional key prefix/namespace. (api.python.langchain.com)

If you want, I can open the specific RedisStore source file and show the exact init signature and supported kwargs.

Citations:


🌐 Web query:

key_value.aio.stores.redis RedisStore constructor parameters

💡 Result:

Constructor signature (keyword-only) and parameters for RedisStore:

  • client: Any | None — a Redis client/connection instance. [1]
  • redis_url: str | None — Redis connection URL (alternative to client). [1]
  • client_kwargs: dict | None — kwargs passed to the Redis client. [1]
  • ttl: int | None — default TTL in seconds for stored keys (None = no expiry). [1]
  • namespace: str | None — optional prefix applied to all keys. [1][2]

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 1

Repository: jlowin/fastmcp

Length of output: 505


Replace the RedisStore placeholder with a runnable, verified example.

The current code at line 255 uses RedisStore(...) without a real connection configuration, error handling, or expected outcomes. Per MDX documentation guidelines, include a complete, runnable example with proper prerequisites and error handling.

Based on the py-key-value library API, RedisStore accepts a url parameter (e.g., redis://localhost:6379) or host/port parameters. Include:

  • A real connection string or configuration parameters
  • Prerequisites (Redis server must be running)
  • Error handling for connection failures
  • Expected behavior after successful initialization

Example pattern (reference: src/fastmcp/server/event_store.py):

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

Expand Down
51 changes: 51 additions & 0 deletions examples/persistent_state/README.md
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
```
85 changes: 85 additions & 0 deletions examples/persistent_state/client.py
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate the file
find . -type f -name "client.py" | grep -E "persistent_state|examples" | head -20

Repository: 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
fi

Repository: 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 URL and console, which violates the repo's Python typing policy.

✅ 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
URL = "http://127.0.0.1:8000/mcp"
console = Console()
URL: str = "http://127.0.0.1:8000/mcp"
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]")

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())
88 changes: 88 additions & 0 deletions examples/persistent_state/client_stdio.py
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove the unused # noqa: E402.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import examples.persistent_state.server as server_module # noqa: E402
import examples.persistent_state.server as server_module
🧰 Tools
🪛 Ruff (0.14.11)

20-20: Unused noqa directive (non-enabled: E402)

Remove unused noqa directive

(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())
42 changes: 42 additions & 0 deletions examples/persistent_state/server.py
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Provide a precise return type for list_session_info.

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,
     }
As per coding guidelines, please provide full type annotations.



if __name__ == "__main__":
server.run(transport="streamable-http")
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ extend-select = [
"UP", # flake8-unused-imports: Catches unused imports
]

[tool.ruff.lint.isort]
known-first-party = ["fastmcp"]

[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "I001", "RUF013"]
Expand Down
Loading
Loading