Skip to content

FuncMetadata.pre_parse_json mis-detects str | None as non-string and corrupts JSON-looking string arguments #3055

Description

@vientooscuro

Title: FuncMetadata.pre_parse_json mis-detects str | None as non-string and corrupts JSON-looking string arguments

Environment

  • mcp version: 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 string

Expected 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

added a commit that references this issue on Jul 4, 2026
fd53853

radheradhe01 commented on Jul 5, 2026

@radheradhe01

I hit this too and confirmed it still reproduces on v2 (main), not just v1 — a str | None tool parameter receiving a JSON-looking string (e.g. '{"blocks": [...]}') gets replaced with a dict before validation and fails with Input should be a valid string. The buggy check is field_info.annotation is not str in pre_parse_json, which is also True for Optional[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 the list arm. So I scoped it narrower: skip pre-parsing only when the field is str or a union of only str/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 commented on Jul 5, 2026

@vientooscuro
Author

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-None arm is exactly str, so str | 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.

added
bugSomething isn't working
P2Moderate issues affecting some users, edge cases, potentially valuable feature
v1Affects the v1.x maintenance line
v2Affects the v2 line (2.x on main)
on Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingv1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions