Skip to content

Support non-serializable values in Context.set_state#3171

Merged
jlowin merged 4 commits intomainfrom
fix/set-state-serializable
Feb 12, 2026
Merged

Support non-serializable values in Context.set_state#3171
jlowin merged 4 commits intomainfrom
fix/set-state-serializable

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Feb 12, 2026

In v3, set_state started requiring JSON-serializable values because session state moved from a plain dict to a PydanticAdapter-backed store. This broke users who store non-serializable objects like HTTP clients — a common pattern for passing state from middleware to tools.

set_state now accepts a serializable keyword argument. When False, the value is stored in a request-scoped dict on the Context instead of the session store, so it doesn't need to be serializable:

class AuthMiddleware(Middleware):
    async def on_call_tool(self, ctx, call_next):
        client = SomeHTTPClient(user_id=ctx.client_id)
        await ctx.fastmcp_context.set_state("client", client, serializable=False)
        return await call_next(ctx)

@mcp.tool
async def my_tool(ctx: Context) -> str:
    client = await ctx.get_state("client")
    return await client.fetch("/data")

The tradeoff is explicit: serializable=False values only live for the current MCP request (tool call, resource read, or prompt render). Default behavior (serializable=True) is unchanged — values persist across requests within the session.

When serialization fails on the default path, the error message now hints at serializable=False so users can self-serve.

Closes #3156

@jlowin jlowin added enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality. v3 Targeted for FastMCP 3 labels Feb 12, 2026
@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. and removed enhancement Improvement to existing functionality. For issues and smaller PR improvements. labels Feb 12, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 12, 2026

Walkthrough

This pull request adds support for non-serializable state values in the FastMCP Context API. The set_state method signature is updated to include a serializable keyword-only parameter defaulting to True. When serializable=False, values are stored in request-scoped internal storage instead of the persistent session state store. The get_state and delete_state methods are modified to check request-scoped storage first before falling back to session-scoped storage. Documentation across multiple files is updated to explain the new serialization behavior, storage backends, and usage examples for storing non-serializable objects like HTTP clients within the current request.

Possibly related PRs

  • Add session-scoped state persistence #2873: Modifies Context state handling by making state async and session-scoped with persistent backend storage, creating overlapping architecture with this PR's request-scoped storage mechanism.
🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding support for non-serializable values in Context.set_state.
Description check ✅ Passed The description provides clear context, explains the breaking change in v3, describes the solution with the serializable parameter, includes code examples, and addresses issue #3156.
Linked Issues check ✅ Passed All coding requirements from issue #3156 are met: set_state now accepts a serializable parameter for non-serializable values, request-scoped storage is implemented, and error messages guide users toward the solution.
Out of Scope Changes check ✅ Passed All changes are directly scoped to addressing issue #3156: core implementation in context.py, API documentation updates, and upgrade guide documentation.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/set-state-serializable

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14af1c55a3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1210 to +1211
if prefixed_key in self._request_state:
return self._request_state[prefixed_key]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear request-scoped shadow when writing serializable state

get_state now always returns _request_state first, but set_state(..., serializable=True) never removes an existing request-scoped entry for the same key. In a request that first calls set_state("k", obj, serializable=False) and later calls set_state("k", "new"), reads still return the stale non-serializable object for the rest of that request, so the update is effectively ignored until a new context is created. This can break middleware/tool flows that upgrade temporary request data to persistent session state under the same key.

Useful? React with 👍 / 👎.

@jlowin
Copy link
Copy Markdown
Member Author

jlowin commented Feb 12, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 12, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
docs/servers/context.mdx (1)

249-265: Consider showing the middleware-to-tool pattern as the primary example.

The current example stores and retrieves a client within the same tool, which doesn't showcase why request-scoped state is useful (you could just use a local variable). The PR's motivating use case — middleware creating a client and a tool consuming it — is more compelling and is already shown in the upgrade guide. Consider aligning this example with that pattern, or at least adding a note pointing to it.

Comment on lines +1182 to +1199
if not serializable:
self._request_state[prefixed_key] = value
return
try:
await self.fastmcp._state_store.put(
key=prefixed_key,
value=StateValue(value=value),
ttl=self._STATE_TTL_SECONDS,
)
except Exception as e:
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
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

🏁 Script executed:

# First, let's examine the file structure and the exact code context
head -20 src/fastmcp/server/context.py | head -5
wc -l src/fastmcp/server/context.py

Repository: jlowin/fastmcp

Length of output: 225


🏁 Script executed:

# Get the specific lines mentioned (1182-1199) with surrounding context
sed -n '1170,1210p' src/fastmcp/server/context.py

Repository: jlowin/fastmcp

Length of output: 1819


🏁 Script executed:

# Check if Pydantic is imported or used in this file
rg "pydantic|Pydantic" src/fastmcp/server/context.py -A 2 -B 2

Repository: jlowin/fastmcp

Length of output: 259


🏁 Script executed:

# Find state_store implementation to understand what exceptions it might raise
find . -type f -name "*.py" | xargs rg "state_store|StateValue" -l | head -10

Repository: jlowin/fastmcp

Length of output: 120


🌐 Web query:

Pydantic v2 exception type raised for non-serializable model validation failure

💡 Result:

In Pydantic v2, a failure due to a value being not JSON-serializable (e.g., when calling model_dump_json() / TypeAdapter.dump_json() / to_json()) raises:

  • pydantic_core.PydanticSerializationError (a ValueError subclass) (docs.pydantic.dev)

This is distinct from validation failures during parsing/validation, which raise:

Citations:


🌐 Web query:

Pydantic v2 SerializationError exception hierarchy

💡 Result:

In Pydantic v2, the “serialization error” you typically see in tracebacks (often as pydantic_core._pydantic_core.PydanticSerializationError) comes from pydantic-core and has this hierarchy:

Exception
└─ ValueError
   ├─ pydantic_core.PydanticSerializationError
   └─ pydantic_core.PydanticSerializationUnexpectedValue
  • PydanticSerializationError bases ValueError. It’s raised when serialization fails (e.g., model_dump_json() / JSON mode can’t serialize a value). [1][2]
  • PydanticSerializationUnexpectedValue also bases ValueError and is meant for “unexpected value encountered during serialization” cases (often handled as warnings internally). [1]

Sources:
[1] https://docs.pydantic.dev/2.12/api/pydantic_core/
[2] https://docs.pydantic.dev/usage/exporting_models/


🏁 Script executed:

# Let's find and examine the StateValue definition
rg "class StateValue" -A 5

Repository: jlowin/fastmcp

Length of output: 336


🏁 Script executed:

# Find the state_store implementation to understand what exceptions it might raise
rg "_state_store" src/fastmcp/server/context.py -B 2 -A 2 | head -30

Repository: jlowin/fastmcp

Length of output: 705


🏁 Script executed:

# Find the state_store class/module
find . -type f -name "*.py" | xargs rg "class.*StateStore|def put" | grep -i store | head -10

Repository: jlowin/fastmcp

Length of output: 40


🏁 Script executed:

# Check imports in context.py for StateValue and related types
sed -n '1,50p' src/fastmcp/server/context.py | rg "import|from"

Repository: jlowin/fastmcp

Length of output: 1238


🏁 Script executed:

# Let's examine the FastMCPBaseModel to understand what it inherits from
rg "class FastMCPBaseModel" -A 3

Repository: jlowin/fastmcp

Length of output: 304


🏁 Script executed:

# Check the state_store implementation - likely in server.py or a separate module
rg "class.*Store|def put" src/fastmcp -A 5 | head -50

Repository: jlowin/fastmcp

Length of output: 592


🏁 Script executed:

# Search for where _state_store is defined/initialized
rg "_state_store.*=" src/fastmcp --type py | head -20

Repository: jlowin/fastmcp

Length of output: 455


🏁 Script executed:

# Look at StateValue more carefully - especially if it has custom serialization
sed -n '1,200p' src/fastmcp/server/server.py | rg -A 10 "class StateValue"

Repository: jlowin/fastmcp

Length of output: 40


🏁 Script executed:

# Check if there are any tests that show what exceptions are raised
find . -type f -name "*.py" -path "*/test*" | xargs rg "set_state|StateValue" -l | head -5

Repository: jlowin/fastmcp

Length of output: 148


🏁 Script executed:

# Look at PydanticAdapter implementation
rg "class PydanticAdapter" -A 10

Repository: jlowin/fastmcp

Length of output: 40


🏁 Script executed:

# Check what's imported related to PydanticAdapter
rg "PydanticAdapter" src/fastmcp -B 5 -A 5 | head -50

Repository: jlowin/fastmcp

Length of output: 3535


🏁 Script executed:

# Let's examine the test file to see what exceptions are expected
cat tests/server/test_context.py

Repository: jlowin/fastmcp

Length of output: 18897


🏁 Script executed:

# Check if StateValue model has any special serialization configuration
rg "class StateValue" -A 20 src/fastmcp/server/server.py

Repository: jlowin/fastmcp

Length of output: 540


🏁 Script executed:

# Search for key_value package usage and any documentation
rg "key_value" --type py -A 1 -B 1 | head -40

Repository: jlowin/fastmcp

Length of output: 2712


🏁 Script executed:

# Check requirements/dependencies to understand the key_value library version
find . -type f \( -name "requirements*.txt" -o -name "pyproject.toml" -o -name "setup.py" \)

Repository: jlowin/fastmcp

Length of output: 272


🏁 Script executed:

# Look for any tests that might trigger PydanticAdapter serialization errors
rg "PydanticAdapter|put.*StateValue" tests/ -A 5 -B 5

Repository: jlowin/fastmcp

Length of output: 2072


🌐 Web query:

key_value.aio.adapters.pydantic PydanticAdapter exception raised serialization error

💡 Result:

That key_value.aio.adapters.pydantic.PydanticAdapter error almost always means Pydantic failed to serialize the value you’re trying to put() (it raises pydantic_core.PydanticSerializationError when it encounters an “unknown” / non-serializable type and no serializer/fallback is defined). (docs.pydantic.dev)

In py-key-value, the PydanticAdapter is meant to persist “pydantic-serializable” types; values that serialize to JSON objects (dict-like) are stored directly, and other types may be wrapped as {"items": ...} to keep storage dict-based. (strawgate.com)

Common causes

  • Your Pydantic model (or nested values) contains a field with a type Pydantic can’t serialize to JSON (e.g., a custom class instance, a DB model object, a callable, an open file handle, etc.).
  • You’re storing something that validates but doesn’t serialize the way you expect (serialization is a separate step in Pydantic v2).

Fixes

  1. Convert/normalize before storing (store primitives / dicts / lists, not raw objects).
  2. Teach Pydantic how to serialize the problematic type, e.g.:
    • @field_serializer(...) / @model_serializer(...) on the model, or
    • ConfigDict(json_encoders=...) (where appropriate), or
    • change the field type to something JSON-friendly (e.g., str/UUID/datetime depending on your needs).
  3. Reproduce locally to find the exact field:
    • Run the equivalent of model.model_dump(mode="json") (or TypeAdapter(T).dump_python(..., mode="json")) on the same value; it should raise the same serialization error and point you at the offending type. (docs.pydantic.dev)

If you paste the full exception/traceback (especially the “Unable to serialize … type …” part) and the model/value you’re storing, I can point to the exact field and the smallest serializer change to fix it.

Citations:


🏁 Script executed:

# Let's look at the actual test that handles non-serializable state
sed -n '400,550p' tests/server/test_context.py

Repository: jlowin/fastmcp

Length of output: 3359


🏁 Script executed:

# Check if there are any actual exception handling tests
rg "set_state.*TypeError|serializable.*Exception" tests/ -A 5 -B 5

Repository: jlowin/fastmcp

Length of output: 887


🏁 Script executed:

# Let's verify StateValue serialization by looking at the exception message expected in tests
rg "serialize.*False\|is not serializable" tests/server/test_context.py -B 5 -A 5

Repository: jlowin/fastmcp

Length of output: 40


🏁 Script executed:

# Let's look at the exact test expectations and run a quick verification
sed -n '290,320p' tests/server/test_context.py

Repository: jlowin/fastmcp

Length of output: 1519


🏁 Script executed:

# Check pyproject.toml to see key_value version constraint
cat pyproject.toml | grep -A 5 -B 5 "key.value\|key-value"

Repository: jlowin/fastmcp

Length of output: 364


🏁 Script executed:

# Now let me trace through the actual exception path by looking at related code
rg "PydanticSerializationError|pydantic_core" src/fastmcp -A 3 -B 3

Repository: jlowin/fastmcp

Length of output: 14982


🏁 Script executed:

# Let's trace the exception path more carefully - look at what PydanticAdapter actually raises
rg "class.*PydanticAdapter|def put" src/fastmcp -A 10 | head -40

Repository: jlowin/fastmcp

Length of output: 40


🏁 Script executed:

# Check if there are imports from pydantic_core that would confirm the exception type
grep -r "PydanticSerializationError" src/fastmcp tests/

Repository: jlowin/fastmcp

Length of output: 349


🌐 Web query:

pydantic_core.PydanticSerializationError ValueError inheritance Pydantic v2

💡 Result:

pydantic_core.PydanticSerializationError in Pydantic v2 is a serialization-time exception (raised by the Rust SchemaSerializer) and it inherits from ValueError. This is expected behavior. [1]

Where it commonly shows up in “ValueError inheritance” situations in v2:

  • Your custom serializer raised a ValueError (or other exception), and pydantic wraps it as a PydanticSerializationError during model_dump_json() / JSON-mode dumping. Example: a serializer tries to float("hello") and raises ValueError, which then appears inside the PydanticSerializationError traceback. [2]
  • An unsupported/unknown type is encountered in JSON serialization, and no fallback is provided; pydantic raises PydanticSerializationError. [3]

Practical fixes (pick what matches your case):

  1. Make your serializer type-safe (don’t run conversions unless the input is the expected type), or use a wrap serializer pattern so non-matching types are delegated instead of crashing. [2]

  2. Ensure JSON-compatible output:

    • Use model_dump(mode="json") / model_dump_json() correctly (JSON mode attempts to coerce to JSON-friendly types). [3]
    • If you store non-JSON-serializable values (e.g., types/classes, mappingproxy, custom containers), add a serializer for them or avoid dumping them to JSON. [4]
  3. If you’re using pydantic-core directly, you can pass a fallback callable to SchemaSerializer.to_json() / to_python() to handle unknown values; otherwise serialization errors raise PydanticSerializationError. [5]

If you paste the exact traceback (and your field type + any @field_serializer / @model_serializer code), I can point to the precise trigger and the minimal change to fix it.

References:
[1] Pydantic docs: PydanticSerializationError bases = ValueError https://docs.pydantic.dev/2.12/api/pydantic_core/
[2] GitHub discussion showing PydanticSerializationError wrapping a ValueError raised inside a serializer pydantic/pydantic#7743
[3] Pydantic serialization docs: unsupported types in JSON mode raise PydanticSerializationError https://docs.pydantic.dev/usage/exporting_models/
[4] GitHub issue example: serialization fails for typing.Type / class objects pydantic/pydantic#7921
[5] SchemaSerializer.to_json() docs: raises PydanticSerializationError if serialization fails and no fallback is provided https://docs.pydantic.dev/2.5/api/pydantic_core/


Narrow the exception catch to ValueError to avoid catching unrelated backend failures.

The "serialize" in str(e).lower() check on line 1192 is a heuristic that works — Pydantic v2 raises pydantic_core.PydanticSerializationError (a ValueError subclass) when model serialization fails. However, the broad except Exception also catches unrelated errors (e.g., network timeouts from the storage backend), runs the string check, and re-raises. This is wasteful and obscures intent.

The suggested refactor to catch (TypeError, ValueError) is valid and safe: serialization errors will still be caught, while other backend failures will propagate naturally:

Suggested refactor
-        except Exception as e:
-            if "serialize" in str(e).lower():
+        except (TypeError, ValueError) as e:
+            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

This is safe because Pydantic v2 raises ValueError subclasses on serialization failure, so narrowing the catch does not miss any expected cases.

🧰 Tools
🪛 Ruff (0.15.0)

[warning] 1193-1198: Avoid specifying long messages outside the exception class

(TRY003)

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Comment on lines +626 to 644
#### `set_state` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/context.py#L1164" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>

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

set_state signature is missing the serializable parameter.

Line 629 shows set_state(self, key: str, value: Any) -> None, but the actual implementation has set_state(self, key: str, value: Any, *, serializable: bool = True) -> None. The docstring below correctly describes the parameter, but the code signature block is incomplete.

Per coding guidelines, this file is bot-generated (docs/python-sdk/**), so this may resolve on the next bot update. Worth confirming the bot picks up keyword-only arguments.

@jlowin jlowin merged commit b62ed34 into main Feb 12, 2026
14 checks passed
@jlowin jlowin deleted the fix/set-state-serializable branch February 12, 2026 21:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. v3 Targeted for FastMCP 3

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FastMCP v3 undocumented breaking change: Context.set_state values must now be serializable

1 participant