Skip to content

Commit 9173fe8

Browse files
committed
Unwrap Annotated before the str-union check in pre_parse_json
1 parent 6b8131b commit 9173fe8

2 files changed

Lines changed: 30 additions & 2 deletions

File tree

src/mcp/server/mcpserver/utilities/func_metadata.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,18 @@ def _is_input_required_type(obj: Any) -> bool:
3333
return isinstance(obj, type) and issubclass(obj, InputRequiredResult)
3434

3535

36+
def _unwrap_annotated(annotation: Any) -> Any:
37+
"""Strip `Annotated[...]` down to its underlying type, e.g. for
38+
`Annotated[str, Field(description=...)]` (idiomatic for described params).
39+
"""
40+
if get_origin(annotation) is Annotated:
41+
return get_args(annotation)[0]
42+
return annotation
43+
44+
3645
def _is_optional_str(annotation: Any) -> bool:
37-
"""Whether `annotation` is `str | None` (`Optional[str]`), modulo `None`.
46+
"""Whether `annotation` is `str | None` (`Optional[str]`), modulo `None`
47+
and `Annotated` wrapping.
3848
3949
Used by `pre_parse_json` to skip JSON pre-parsing for such annotations:
4050
unlike e.g. `str | list[str]`, no member of this union other than `str`
@@ -46,7 +56,7 @@ def _is_optional_str(annotation: Any) -> bool:
4656
origin = get_origin(annotation)
4757
if not is_union_origin(origin):
4858
return False
49-
non_none_args = [arg for arg in get_args(annotation) if arg is not type(None)]
59+
non_none_args = [_unwrap_annotated(arg) for arg in get_args(annotation) if arg is not type(None)]
5060
return non_none_args == [str]
5161

5262

tests/server/mcpserver/test_func_metadata.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,24 @@ def func_with_optional_str(body: str | None = None): # pragma: no cover
233233
assert result["body"] is None
234234

235235

236+
def test_annotated_optional_str_is_never_json_pre_parsed():
237+
"""`Annotated[str, Field(...)] | None` — idiomatic FastMCP style for a
238+
described optional string param — must get the same treatment as plain
239+
`str | None`: the `Annotated` wrapper must not defeat the `str` check.
240+
"""
241+
242+
def func_with_annotated_str(
243+
body: Annotated[str, Field(description="a body")] | None = None,
244+
): # pragma: no cover
245+
return body
246+
247+
meta = func_metadata(func_with_annotated_str)
248+
249+
json_payload = '{"blocks": ["a", "b"]}'
250+
result = meta.pre_parse_json({"body": json_payload})
251+
assert result["body"] == json_payload
252+
253+
236254
def test_skip_names():
237255
"""Test that skipped parameters are not included in the model"""
238256

0 commit comments

Comments
 (0)