Repository navigation
FuncMetadata.pre_parse_json mis-detects str | None as non-string and corrupts JSON-looking string arguments #3055
Description
Title: FuncMetadata.pre_parse_json mis-detects str | None as non-string and corrupts JSON-looking string arguments
Environment
mcpversion: 1.27.0- Python: 3.10.12
- Transport: streamable-http (also affects stdio — the bug is transport-agnostic)
Summary
FuncMetadata.pre_parse_json() in mcp/server/fastmcp/utilities/func_metadata.py decides whether to json.loads() a string argument based on:
if isinstance(data_value, str) and field_info.annotation is not str:This check is meant to catch cases where a client (e.g. Claude Desktop) stringifies a list/dict argument that should really be a Python object. But field_info.annotation is not str is True for Optional[str] / str | None as well, since that annotation is not literally str. So any optional string parameter gets the same treatment as a list/dict/model parameter.
If the caller passes a valid string value for such a parameter that also happens to parse as a JSON object or array — e.g. a JSON-serialized template body like '{"blocks": [...]}' — the value silently gets replaced with a dict/list before the pydantic argument model is validated. Validation then fails with something like:
1 validation error for my_tool_nameArguments
body
Input should be a valid string [type=string_type, input_value={'blocks': [...]}, input_type=dict]
...even though the caller sent a perfectly valid string and the tool signature explicitly declares body: str | None.
Minimal repro
from typing import Any
import json
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
async def my_tool(body: str | None = None) -> dict[str, Any]:
return {"body": body}
meta = func_metadata(my_tool)
data = {"body": json.dumps({"blocks": ["a", "b"]})}
new_data = meta.pre_parse_json(data)
print(type(new_data["body"])) # <class 'dict'> -- should be <class 'str'>
meta.arg_model.model_validate(new_data) # raises: Input should be a valid stringExpected behavior
A parameter typed str | None (or any Union that includes str) should not have its string value re-interpreted as JSON, since the raw string is already a valid value for that field. Pre-parsing should only kick in when a plain str could never satisfy the annotation (e.g. list[str], dict[str, Any], a Pydantic model, int, etc.).
Suggested fix
Replace the identity check with one that walks Union/X | Y members:
def _annotation_accepts_str(annotation: Any) -> bool:
origin = typing.get_origin(annotation)
if origin is typing.Union or origin is types.UnionType:
return any(_annotation_accepts_str(arg) for arg in typing.get_args(annotation))
return annotation is str
# in pre_parse_json:
if isinstance(data_value, str) and not _annotation_accepts_str(field_info.annotation):
...Impact
Any FastMCP tool with an Optional[str] (or str | None) parameter breaks whenever a caller passes a string value that happens to be valid JSON for an object/array (JSON-in-a-string payloads: template bodies, block-based editor content, serialized configs, etc.). We hit this in production with a Unisender email-template MCP server where body: str | None holds a JSON block structure — every update_email_template / create_email_template call with a block-based template failed validation until we monkey-patched FuncMetadata.pre_parse_json locally with the fix above.
Activity
fix: don't JSON pre-parse string args whose annotation only accepts s…
radheradhe01 commentedon Jul 5, 2026
I hit this too and confirmed it still reproduces on v2 (
main), not just v1 — astr | Nonetool parameter receiving a JSON-looking string (e.g.'{"blocks": [...]}') gets replaced with adictbefore validation and fails withInput should be a valid string. The buggy check isfield_info.annotation is not strinpre_parse_json, which is alsoTrueforOptional[str].I'd like to take this. I have a fix + tests ready (full suite passing at 100% coverage locally).
One heads-up on approach: the fix suggested above — "skip if any union member is
str" — would regress existing behavior.str | list[str]is deliberately pre-parsed (there's a test for it) so a stringified JSON array can populate thelistarm. So I scoped it narrower: skip pre-parsing only when the field isstror a union of onlystr/None— i.e. no container arm a parsed value could go into. Unions that include a container still pre-parse as before. Happy to go with the broader approach instead if you'd prefer.Disclosure: I used AI assistance (Claude Code) to help locate the root cause and draft the change; I've reviewed it and understand it.
vientooscuro commentedon Jul 5, 2026
Thanks for confirming the repro on
main— and good to see we converged on the same scoping independently (skip pre-parsing only when the union's non-Nonearm is exactlystr, sostr | list[str]etc. still pre-parse as before).I already have a PR open for this: #3056. Feel free to take a look — no need to duplicate the work. It also just picked up a fix for
Annotated[str, Field(...)] | None(idiomatic FastMCP style for described params), flagged by another commenter on the PR, in case that's useful for your version too.chore(mcp): copy all SDK metadata fields and bound mcp below 3
inanna-malick commentedon Oct 14, 2026
Adding a deterministic public-client reproduction of this issue, including a silent-coercion case: for a
str | Noneparameter, the literal string"null"reaches the handler asNone. The literal string"[]"instead produces an error result. Both inputs satisfy the advertised schema.The same test first checks nonnullable
strand a nullable parameter withvalidation_alias="wire"; both controls preserve the original strings. This gives the existing fix a compact set of regression cases without changing the intended handling of unions containing containers.Failing test on a test-only branch.
Reproduced on v2.3.0 (
2118f14f) and main source754254c0, with Python 3.14.7 and Pydantic 2.12.5: both parameterized cases fail; all eight existing tests in the file pass.AI-assisted investigation and test reproduction, with a human in the loop.