diff --git a/docs/development/upgrade-guide.mdx b/docs/development/upgrade-guide.mdx
index f8f63c66a3..70a60b8303 100644
--- a/docs/development/upgrade-guide.mdx
+++ b/docs/development/upgrade-guide.mdx
@@ -91,6 +91,20 @@ await ctx.set_state("key", "value")
value = await ctx.get_state("key")
```
+#### State Values Must Be Serializable
+
+Session state values must now be JSON-serializable by default (dicts, lists, strings, numbers, etc.), since state is persisted across requests using a pluggable storage backend.
+
+If you need to store non-serializable values (e.g., passing an HTTP client from middleware to a tool), use `serializable=False`. These values are request-scoped and only available during the current tool call, resource read, or prompt render:
+
+```python
+# Middleware sets up a client for the current request
+await ctx.set_state("client", my_http_client, serializable=False)
+
+# Tool retrieves it in the same request
+client = await ctx.get_state("client")
+```
+
#### Server Banner Environment Variable Renamed
`FASTMCP_SHOW_CLI_BANNER` is now `FASTMCP_SHOW_SERVER_BANNER`.
diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx
index 0defded514..5c266ed117 100644
--- a/docs/python-sdk/fastmcp-server-context.mdx
+++ b/docs/python-sdk/fastmcp-server-context.mdx
@@ -77,6 +77,9 @@ async def my_tool(x: int, ctx: Context) -> str:
await ctx.set_state("key", "value")
value = await ctx.get_state("key")
+ # Store non-serializable values for the current request only
+ await ctx.set_state("client", http_client, serializable=False)
+
return str(x)
```
@@ -96,7 +99,7 @@ The context is optional - tools that don't need it can omit the parameter.
**Methods:**
-#### `is_background_task`
+#### `is_background_task`
```python
is_background_task(self) -> bool
@@ -109,7 +112,7 @@ task-aware implementations that can pause the task and wait for
client input.
-#### `task_id`
+#### `task_id`
```python
task_id(self) -> str | None
@@ -120,7 +123,7 @@ Get the background task ID if running in a background task.
Returns None if not running in a background task context.
-#### `fastmcp`
+#### `fastmcp`
```python
fastmcp(self) -> FastMCP
@@ -129,7 +132,7 @@ fastmcp(self) -> FastMCP
Get the FastMCP instance.
-#### `request_context`
+#### `request_context`
```python
request_context(self) -> RequestContext[ServerSession, Any, Request] | None
@@ -158,7 +161,7 @@ async def on_request(self, context, call_next):
```
-#### `lifespan_context`
+#### `lifespan_context`
```python
lifespan_context(self) -> dict[str, Any]
@@ -185,7 +188,7 @@ def my_tool(ctx: Context) -> str:
```
-#### `report_progress`
+#### `report_progress`
```python
report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None
@@ -202,7 +205,7 @@ Works in both foreground (MCP progress notifications) and background
- `message`: Optional status message describing current progress
-#### `list_resources`
+#### `list_resources`
```python
list_resources(self) -> list[SDKResource]
@@ -214,7 +217,7 @@ List all available resources from the server.
- List of Resource objects available on the server
-#### `list_prompts`
+#### `list_prompts`
```python
list_prompts(self) -> list[SDKPrompt]
@@ -226,7 +229,7 @@ List all available prompts from the server.
- List of Prompt objects available on the server
-#### `get_prompt`
+#### `get_prompt`
```python
get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult
@@ -242,7 +245,7 @@ Get a prompt by name with optional arguments.
- The prompt result
-#### `read_resource`
+#### `read_resource`
```python
read_resource(self, uri: str | AnyUrl) -> ResourceResult
@@ -257,7 +260,7 @@ Read a resource by URI.
- ResourceResult with contents
-#### `log`
+#### `log`
```python
log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -275,7 +278,7 @@ Messages sent to Clients are also logged to the `fastmcp.server.context.to_clien
- `extra`: Optional mapping for additional arguments
-#### `transport`
+#### `transport`
```python
transport(self) -> TransportType | None
@@ -287,7 +290,7 @@ Returns the transport type used to run this server: "stdio", "sse",
or "streamable-http". Returns None if called outside of a server context.
-#### `client_supports_extension`
+#### `client_supports_extension`
```python
client_supports_extension(self, extension_id: str) -> bool
@@ -312,7 +315,7 @@ Example::
return "text-only client"
-#### `client_id`
+#### `client_id`
```python
client_id(self) -> str | None
@@ -321,7 +324,7 @@ client_id(self) -> str | None
Get the client ID if available.
-#### `request_id`
+#### `request_id`
```python
request_id(self) -> str
@@ -332,7 +335,7 @@ Get the unique ID for this request.
Raises RuntimeError if MCP request context is not available.
-#### `session_id`
+#### `session_id`
```python
session_id(self) -> str
@@ -349,7 +352,7 @@ the same client session.
- for other transports.
-#### `session`
+#### `session`
```python
session(self) -> ServerSession
@@ -363,7 +366,7 @@ In background task mode: Returns the session stored at Context creation.
Raises RuntimeError if no session is available.
-#### `debug`
+#### `debug`
```python
debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -374,7 +377,7 @@ Send a `DEBUG`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `info`
+#### `info`
```python
info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -385,7 +388,7 @@ Send a `INFO`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `warning`
+#### `warning`
```python
warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -396,7 +399,7 @@ Send a `WARNING`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `error`
+#### `error`
```python
error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None
@@ -407,7 +410,7 @@ Send a `ERROR`-level message to the connected MCP Client.
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
-#### `list_roots`
+#### `list_roots`
```python
list_roots(self) -> list[Root]
@@ -416,7 +419,7 @@ list_roots(self) -> list[Root]
List the roots available to the server, as indicated by the client.
-#### `send_notification`
+#### `send_notification`
```python
send_notification(self, notification: mcp.types.ServerNotificationType) -> None
@@ -428,7 +431,7 @@ Send a notification to the client immediately.
- `notification`: An MCP notification instance (e.g., ToolListChangedNotification())
-#### `close_sse_stream`
+#### `close_sse_stream`
```python
close_sse_stream(self) -> None
@@ -446,7 +449,7 @@ Instead of holding a connection open for minutes, you can periodically close
and let the client reconnect.
-#### `sample_step`
+#### `sample_step`
```python
sample_step(self, messages: str | Sequence[str | SamplingMessage]) -> SampleStep
@@ -489,7 +492,7 @@ regardless of this setting.
- - .text: The text content (if any)
-#### `sample`
+#### `sample`
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT]
@@ -498,7 +501,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[
Overload: With result_type, returns SamplingResult[ResultT].
-#### `sample`
+#### `sample`
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[str]
@@ -507,7 +510,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[
Overload: Without result_type, returns SamplingResult[str].
-#### `sample`
+#### `sample`
```python
sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] | SamplingResult[str]
@@ -555,43 +558,43 @@ regardless of this setting.
- - .history: All messages exchanged during sampling
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
```
-#### `elicit`
+#### `elicit`
```python
elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
@@ -620,40 +623,53 @@ type or dataclass or BaseModel. If it is a primitive type, an
object schema with a single "value" field will be generated.
-#### `set_state`
+#### `set_state`
```python
set_state(self, key: str, value: Any) -> None
```
-Set a value in the session-scoped state store.
+Set a value in the state store.
+
+By default, values are stored in the session-scoped state store and
+persist across requests within the same MCP session. Values must be
+JSON-serializable (dicts, lists, strings, numbers, etc.).
+
+For non-serializable values (e.g., HTTP clients, database connections),
+pass ``serializable=False``. These values are stored in a request-scoped
+dict and only live for the current MCP request (tool call, resource
+read, or prompt render). They will not be available in subsequent
+requests.
-Values persist across requests within the same MCP session.
The key is automatically prefixed with the session identifier.
-State expires after 1 day to prevent unbounded memory growth.
-#### `get_state`
+#### `get_state`
```python
get_state(self, key: str) -> Any
```
-Get a value from the session-scoped state store.
+Get a value from the state store.
+
+Checks request-scoped state first (set with ``serializable=False``),
+then falls back to the session-scoped state store.
Returns None if the key is not found.
-#### `delete_state`
+#### `delete_state`
```python
delete_state(self, key: str) -> None
```
-Delete a value from the session-scoped state store.
+Delete a value from the state store.
+
+Removes from both request-scoped and session-scoped stores.
-#### `enable_components`
+#### `enable_components`
```python
enable_components(self) -> None
@@ -677,7 +693,7 @@ ResourceListChangedNotification, and PromptListChangedNotification.
- `match_all`: If True, matches all components regardless of other criteria.
-#### `disable_components`
+#### `disable_components`
```python
disable_components(self) -> None
@@ -701,7 +717,7 @@ ResourceListChangedNotification, and PromptListChangedNotification.
- `match_all`: If True, matches all components regardless of other criteria.
-#### `reset_visibility`
+#### `reset_visibility`
```python
reset_visibility(self) -> None
diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx
index 78e71e53c7..a10161baf7 100644
--- a/docs/servers/context.mdx
+++ b/docs/servers/context.mdx
@@ -238,14 +238,32 @@ async def get_counter(ctx: Context) -> int:
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: 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
+- **`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
State methods are async and require `await`. State expires after 1 day to prevent unbounded memory growth.
+#### 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:
diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py
index faa212a2ae..c17e13f03c 100644
--- a/src/fastmcp/server/context.py
+++ b/src/fastmcp/server/context.py
@@ -161,6 +161,9 @@ async def my_tool(x: int, ctx: Context) -> str:
await ctx.set_state("key", "value")
value = await ctx.get_state("key")
+ # Store non-serializable values for the current request only
+ await ctx.set_state("client", http_client, serializable=False)
+
return str(x)
```
@@ -194,6 +197,8 @@ def __init__(
self._tokens: list[Token] = []
# Background task support (SEP-1686)
self._task_id: str | None = task_id
+ # Request-scoped state for non-serializable values (serializable=False)
+ self._request_state: dict[str, Any] = {}
@property
def is_background_task(self) -> bool:
@@ -1156,32 +1161,69 @@ def _make_state_key(self, key: str) -> str:
"""Create session-prefixed key for state storage."""
return f"{self.session_id}:{key}"
- async def set_state(self, key: str, value: Any) -> None:
- """Set a value in the session-scoped state store.
+ async def set_state(
+ self, key: str, value: Any, *, serializable: bool = True
+ ) -> None:
+ """Set a value in the state store.
+
+ By default, values are stored in the session-scoped state store and
+ persist across requests within the same MCP session. Values must be
+ JSON-serializable (dicts, lists, strings, numbers, etc.).
+
+ For non-serializable values (e.g., HTTP clients, database connections),
+ pass ``serializable=False``. These values are stored in a request-scoped
+ dict and only live for the current MCP request (tool call, resource
+ read, or prompt render). They will not be available in subsequent
+ requests.
- Values persist across requests within the same MCP session.
The key is automatically prefixed with the session identifier.
- State expires after 1 day to prevent unbounded memory growth.
"""
prefixed_key = self._make_state_key(key)
- await self.fastmcp._state_store.put(
- key=prefixed_key,
- value=StateValue(value=value),
- ttl=self._STATE_TTL_SECONDS,
- )
+ if not serializable:
+ self._request_state[prefixed_key] = value
+ return
+ # Clear any request-scoped shadow so the session value is visible
+ self._request_state.pop(prefixed_key, None)
+ try:
+ await self.fastmcp._state_store.put(
+ key=prefixed_key,
+ 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.
+ if "serialize" in str(e).lower():
+ raise TypeError(
+ f"Value for state key {key!r} is not serializable. "
+ f"Use set_state({key!r}, value, serializable=False) to store "
+ f"non-serializable values. Note: non-serializable state is "
+ f"request-scoped and will not persist across requests."
+ ) from e
+ raise
async def get_state(self, key: str) -> Any:
- """Get a value from the session-scoped state store.
+ """Get a value from the state store.
+
+ Checks request-scoped state first (set with ``serializable=False``),
+ then falls back to the session-scoped state store.
Returns None if the key is not found.
"""
prefixed_key = self._make_state_key(key)
+ if prefixed_key in self._request_state:
+ return self._request_state[prefixed_key]
result = await self.fastmcp._state_store.get(key=prefixed_key)
return result.value if result is not None else None
async def delete_state(self, key: str) -> None:
- """Delete a value from the session-scoped state store."""
+ """Delete a value from the state store.
+
+ Removes from both request-scoped and session-scoped stores.
+ """
prefixed_key = self._make_state_key(key)
+ self._request_state.pop(prefixed_key, None)
await self.fastmcp._state_store.delete(key=prefixed_key)
# -------------------------------------------------------------------------
diff --git a/tests/server/test_context.py b/tests/server/test_context.py
index 373286d8a8..921257c897 100644
--- a/tests/server/test_context.py
+++ b/tests/server/test_context.py
@@ -242,6 +242,97 @@ async def store_and_read(value: str, ctx: Context) -> dict:
assert data3["session_id"] != session_id_1 # Different session
+class TestContextStateSerializable:
+ """Tests for the serializable parameter on set_state."""
+
+ async def test_set_state_serializable_false_stores_arbitrary_objects(self):
+ """Non-serializable objects can be stored with serializable=False."""
+ server = FastMCP("test")
+ mock_session = MagicMock()
+
+ class MyClient:
+ def __init__(self):
+ self.connected = True
+
+ client = MyClient()
+
+ async with Context(fastmcp=server, session=mock_session) as context:
+ await context.set_state("client", client, serializable=False)
+ result = await context.get_state("client")
+ assert result is client
+ assert result.connected is True
+
+ async def test_set_state_serializable_false_does_not_persist_across_requests(self):
+ """Non-serializable state is request-scoped and gone in a new context."""
+ server = FastMCP("test")
+ mock_session = MagicMock()
+
+ async with Context(fastmcp=server, session=mock_session) as context:
+ await context.set_state("key", object(), serializable=False)
+ assert await context.get_state("key") is not None
+
+ async with Context(fastmcp=server, session=mock_session) as context:
+ assert await context.get_state("key") is None
+
+ async def test_set_state_serializable_true_rejects_non_serializable(self):
+ """Default set_state raises TypeError for non-serializable values."""
+ server = FastMCP("test")
+ mock_session = MagicMock()
+
+ async with Context(fastmcp=server, session=mock_session) as context:
+ with pytest.raises(TypeError, match="serializable=False"):
+ await context.set_state("key", object())
+
+ async def test_set_state_serializable_false_shadows_session_state(self):
+ """Request-scoped state shadows session-scoped state for the same key."""
+ server = FastMCP("test")
+ mock_session = MagicMock()
+
+ async with Context(fastmcp=server, session=mock_session) as context:
+ await context.set_state("key", "session-value")
+ assert await context.get_state("key") == "session-value"
+
+ await context.set_state("key", "request-value", serializable=False)
+ assert await context.get_state("key") == "request-value"
+
+ async def test_delete_state_removes_from_both_stores(self):
+ """delete_state clears both request-scoped and session-scoped values."""
+ server = FastMCP("test")
+ mock_session = MagicMock()
+
+ async with Context(fastmcp=server, session=mock_session) as context:
+ await context.set_state("key", "session-value")
+ await context.set_state("key", "request-value", serializable=False)
+ assert await context.get_state("key") == "request-value"
+
+ await context.delete_state("key")
+ assert await context.get_state("key") is None
+
+ async def test_serializable_state_still_persists_across_requests(self):
+ """Serializable state (default) still persists across requests."""
+ server = FastMCP("test")
+ mock_session = MagicMock()
+
+ async with Context(fastmcp=server, session=mock_session) as context:
+ await context.set_state("key", "persistent")
+
+ async with Context(fastmcp=server, session=mock_session) as context:
+ assert await context.get_state("key") == "persistent"
+
+ async def test_serializable_write_clears_request_scoped_shadow(self):
+ """Writing serializable state clears any request-scoped shadow for the same key."""
+ server = FastMCP("test")
+ mock_session = MagicMock()
+
+ async with Context(fastmcp=server, session=mock_session) as context:
+ await context.set_state("key", "request-value", serializable=False)
+ assert await context.get_state("key") == "request-value"
+
+ # Serializable write should clear the shadow
+ await context.set_state("key", "session-value")
+ assert await context.get_state("key") == "session-value"
+
+
class TestContextMeta:
"""Test suite for Context meta functionality."""