Support non-serializable values in Context.set_state#3171
Conversation
WalkthroughThis pull request adds support for non-serializable state values in the FastMCP Context API. The Possibly related PRs
🚥 Pre-merge checks | ✅ 6✅ Passed checks (6 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
💡 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".
| if prefixed_key in self._request_state: | ||
| return self._request_state[prefixed_key] |
There was a problem hiding this comment.
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 👍 / 👎.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
🧩 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.pyRepository: 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.pyRepository: 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 2Repository: 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 -10Repository: 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(aValueErrorsubclass) (docs.pydantic.dev)
This is distinct from validation failures during parsing/validation, which raise:
pydantic.ValidationError(docs.pydantic.dev)
Citations:
- 1: https://docs.pydantic.dev/2.12/api/pydantic_core/?utm_source=openai
- 2: https://docs.pydantic.dev/2.5/errors/errors/?utm_source=openai
🌐 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
PydanticSerializationErrorbasesValueError. It’s raised when serialization fails (e.g.,model_dump_json()/ JSON mode can’t serialize a value). [1][2]PydanticSerializationUnexpectedValuealso basesValueErrorand 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 5Repository: 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 -30Repository: 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 -10Repository: 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 3Repository: 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 -50Repository: jlowin/fastmcp
Length of output: 592
🏁 Script executed:
# Search for where _state_store is defined/initialized
rg "_state_store.*=" src/fastmcp --type py | head -20Repository: 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 -5Repository: jlowin/fastmcp
Length of output: 148
🏁 Script executed:
# Look at PydanticAdapter implementation
rg "class PydanticAdapter" -A 10Repository: jlowin/fastmcp
Length of output: 40
🏁 Script executed:
# Check what's imported related to PydanticAdapter
rg "PydanticAdapter" src/fastmcp -B 5 -A 5 | head -50Repository: 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.pyRepository: 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.pyRepository: 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 -40Repository: 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 5Repository: 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
- Convert/normalize before storing (store primitives / dicts / lists, not raw objects).
- Teach Pydantic how to serialize the problematic type, e.g.:
@field_serializer(...)/@model_serializer(...)on the model, orConfigDict(json_encoders=...)(where appropriate), or- change the field type to something JSON-friendly (e.g.,
str/UUID/datetimedepending on your needs).
- Reproduce locally to find the exact field:
- Run the equivalent of
model.model_dump(mode="json")(orTypeAdapter(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)
- Run the equivalent of
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:
- 1: https://docs.pydantic.dev/latest/api/type_adapter/?utm_source=openai
- 2: https://strawgate.com/py-key-value/api/adapters/
- 3: https://docs.pydantic.dev/latest/api/type_adapter/?utm_source=openai
🏁 Script executed:
# Let's look at the actual test that handles non-serializable state
sed -n '400,550p' tests/server/test_context.pyRepository: 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 5Repository: 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 5Repository: 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.pyRepository: 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 3Repository: 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 -40Repository: 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 aPydanticSerializationErrorduringmodel_dump_json()/ JSON-mode dumping. Example: a serializer tries tofloat("hello")and raisesValueError, which then appears inside thePydanticSerializationErrortraceback. [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):
-
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]
-
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]
- Use
-
If you’re using
pydantic-coredirectly, you can pass afallbackcallable toSchemaSerializer.to_json()/to_python()to handle unknown values; otherwise serialization errors raisePydanticSerializationError. [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
+ raiseThis 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)
| #### `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. |
There was a problem hiding this comment.
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.
In v3,
set_statestarted requiring JSON-serializable values because session state moved from a plain dict to aPydanticAdapter-backed store. This broke users who store non-serializable objects like HTTP clients — a common pattern for passing state from middleware to tools.set_statenow accepts aserializablekeyword argument. WhenFalse, 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:The tradeoff is explicit:
serializable=Falsevalues 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=Falseso users can self-serve.Closes #3156