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."""