Repository navigation
Fix pre_parse_json corrupting str | None values that look like JSON - #3056
vientooscuro wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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 branchreproduces it (and heads-up: CI enforces 100% branch coverage on changed code, so the new branch will need that test anyway).
|
Good catch, thanks — pushed a fix in 9173fe8. Ran the full suite with coverage to be safe (not just |
chrikrah
left a comment
There was a problem hiding this comment.
@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?
Summary
FuncMetadata.pre_parse_json()decided whether tojson.loads()a stringargument based on
field_info.annotation is not str. That check isTruefor
str | Noneas well (it'sOptional[str], not literallystr), so anyoptional string parameter got the same "maybe this is JSON" treatment as a
list/dictparameter.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/listbefore the pydantic argument model wasvalidated, and validation then failed with
Input should be a valid stringeven though the caller's string was perfectly valid for that field.
Fixes #3055.
Fix
Added
_is_optional_str(), which recognizesstr | None(any union whoseonly non-
Nonemember isstr) and skips JSON pre-parsing for it, since nomember of such a union other than
strcould ever be produced by decodingJSON — so pre-parsing that annotation can only corrupt, never help.
str | list[str](and other unions with a real non-strmember) keep theexisting 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_strtest, which still passes.Test plan
test_optional_str_is_never_json_pre_parsedcovering dict-like,list-like, plain-string, and
Nonevalues for astr | Noneparam.uv run pytest tests/server/mcpserver/test_func_metadata.py— 43 passeduv run pytest tests/server/mcpserver(full suite) — 526 passeduv run ruff check/ruff format --checkon changed files