Skip to content

Fix pre_parse_json corrupting str | None values that look like JSON - #3056

Open
vientooscuro wants to merge 3 commits into
modelcontextprotocol:mainfrom
vientooscuro:fix/pre-parse-json-optional-str
Open

vientooscuro wants to merge 3 commits into
modelcontextprotocol:mainfrom
vientooscuro:fix/pre-parse-json-optional-str

Conversation

@vientooscuro

Copy link
Copy Markdown

Summary

FuncMetadata.pre_parse_json() decided whether to json.loads() a string
argument based on field_info.annotation is not str. That check is True
for str | None as well (it's Optional[str], not literally str), so any
optional string parameter got the same "maybe this is JSON" treatment as a
list/dict parameter.

If a caller passes a valid string that also happens to parse as a JSON
object/array — e.g. a JSON-serialized payload passed as a plain string field
(block-based template body, serialized config, etc.) — the value silently
got replaced with a dict/list before the pydantic argument model was
validated, and validation then failed with Input should be a valid string
even though the caller's string was perfectly valid for that field.

Fixes #3055.

Fix

Added _is_optional_str(), which recognizes str | None (any union whose
only non-None member is str) and skips JSON pre-parsing for it, since no
member of such a union other than str could ever be produced by decoding
JSON — so pre-parsing that annotation can only corrupt, never help.

str | list[str] (and other unions with a real non-str member) keep the
existing behavior, since a JSON array/object there is meaningfully different
from the raw string and should still be pre-parsed — covered by the existing
test_str_vs_list_str test, which still passes.

Test plan

  • Added test_optional_str_is_never_json_pre_parsed covering dict-like,
    list-like, plain-string, and None values for a str | None param.
  • uv run pytest tests/server/mcpserver/test_func_metadata.py — 43 passed
  • uv run pytest tests/server/mcpserver (full suite) — 526 passed
  • uv run ruff check / ruff format --check on changed files

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 2 files

Tip: cubic could auto-approve low-risk PRs like this, if it thinks it's safe to merge. Learn more

Re-trigger cubic

@akminx akminx left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I was working on a fix for #3055 as well (didn't spot this PR first — closing mine, #3062, in favor of this one since it came first and covers the core case well).

One gap worth closing while you're in here: _is_optional_str compares union members against str directly, but pydantic preserves Annotated inside union members — FieldInfo.annotation for

def my_tool(body: Annotated[str, Field(description="a body")] | None = None): ...

is Optional[Annotated[str, FieldInfo(...)]], so non_none_args == [str] is False and the corruption from #3055 persists for that spelling — which is idiomatic FastMCP style for described params. Unwrapping Annotated members (e.g. typing.get_args(arg)[0] when get_origin(arg) is Annotated) before the comparison covers it; a test like

def func_with_annotated_str(
    body: Annotated[str, Field(description="a body")] | None = None,
): ...

meta = func_metadata(func_with_annotated_str)
result = meta.pre_parse_json({"body": '{"blocks": ["a", "b"]}'})
assert result["body"] == '{"blocks": ["a", "b"]}'  # fails on this branch

reproduces it (and heads-up: CI enforces 100% branch coverage on changed code, so the new branch will need that test anyway).

@vientooscuro

Copy link
Copy Markdown
Author

Good catch, thanks — pushed a fix in 9173fe8. _is_optional_str now unwraps Annotated members before comparing to str, so Annotated[str, Field(description=...)] | None is treated the same as plain str | None. Added your repro as test_annotated_optional_str_is_never_json_pre_parsed.

Ran the full suite with coverage to be safe (not just tests/server/mcpserver) — 5243 passed, 100% branch coverage repo-wide.

@chrikrah chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@vientooscuro the guard extends what #1113 did for str to str | None and Annotated[str, ...] | None, and the probe below agrees. Keeping it to an exact str arm is deliberate, per your note on #3055.

non-blocking: #2032 by @adityuhkapoor, open since February for #1873, changes the same check with a wider rule. Its _should_pre_parse_json skips pre-parsing when every union member is in {str, int, float, bool, None}, so it also covers str | int | None, which still decodes to a dict on this branch. In May #2554 was closed as a duplicate of #2032.

$ cd <export of b9ca41e> && python -c 'import mcp; print(mcp.__file__)'
<export of b9ca41e>/src/mcp/__init__.py   # CPython 3.14.7, merge-base 2118f14

$ python -m pytest tests/server/mcpserver/test_func_metadata.py -q -n 4   # b9ca41e
60 passed
$ ruff check src/mcp/server/mcpserver tests/server/mcpserver/test_func_metadata.py   # ruff 0.12.12
All checks passed!

# production guard removed, tests kept:   2 failed, 58 passed
# Annotated unwrap removed, tests kept:   1 failed, 59 passed

$ probe: pre_parse_json({'a': '{"k":1}'}) per annotation
str | None                  -> '{"k":1}'
Annotated[str|None, Field]  -> '{"k":1}'
str | int | None            -> {'k': 1}
str | list[str] | None      -> ['a']     (intended: a list member can take it)

$ git log -S'is not str' -- src/mcp/server/   # line-level blame lands on the #2974 move
0a4e8d4e Giuseppe Coviello Avoiding to parse string arguments containing valid JSON if the argument annotation is str (#1113)

# not run: the full suite, pyright, the #2032 branch

@maxisbey should #3055 and #1873 close through one pull request, and with which scope?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants