Skip to content

Commit 6b8131b

Browse files
committed
Fix pre_parse_json corrupting str | None values that look like JSON
1 parent 220d362 commit 6b8131b

2 files changed

Lines changed: 51 additions & 1 deletion

File tree

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,23 @@ def _is_input_required_type(obj: Any) -> bool:
3333
return isinstance(obj, type) and issubclass(obj, InputRequiredResult)
3434

3535

36+
def _is_optional_str(annotation: Any) -> bool:
37+
"""Whether `annotation` is `str | None` (`Optional[str]`), modulo `None`.
38+
39+
Used by `pre_parse_json` to skip JSON pre-parsing for such annotations:
40+
unlike e.g. `str | list[str]`, no member of this union other than `str`
41+
could ever be produced by decoding a string as JSON, so attempting to
42+
parse it only risks corrupting a valid string value that happens to look
43+
like a JSON object/array (e.g. a JSON-serialized payload passed as a
44+
plain string parameter).
45+
"""
46+
origin = get_origin(annotation)
47+
if not is_union_origin(origin):
48+
return False
49+
non_none_args = [arg for arg in get_args(annotation) if arg is not type(None)]
50+
return non_none_args == [str]
51+
52+
3653
class StrictJsonSchema(GenerateJsonSchema):
3754
"""A JSON schema generator that raises exceptions instead of emitting warnings.
3855
@@ -169,7 +186,11 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
169186
continue
170187

171188
field_info = key_to_field_info[data_key]
172-
if isinstance(data_value, str) and field_info.annotation is not str:
189+
if (
190+
isinstance(data_value, str)
191+
and field_info.annotation is not str
192+
and not _is_optional_str(field_info.annotation)
193+
):
173194
try:
174195
pre_parsed = json.loads(data_value)
175196
except json.JSONDecodeError:

tests/server/mcpserver/test_func_metadata.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,35 @@ def func_with_str_types(str_or_list: str | list[str]): # pragma: no cover
204204
assert result["str_or_list"] == ["hello", "world"]
205205

206206

207+
def test_optional_str_is_never_json_pre_parsed():
208+
"""`str | None` must never be JSON-decoded, even if the value looks like a
209+
JSON object/array (e.g. a JSON-serialized payload passed as a plain string).
210+
211+
Regression test: `field_info.annotation is not str` used to be True for
212+
`str | None`, so a value like a JSON-serialized template body would get
213+
silently turned into a dict and fail pydantic validation downstream.
214+
"""
215+
216+
def func_with_optional_str(body: str | None = None): # pragma: no cover
217+
return body
218+
219+
meta = func_metadata(func_with_optional_str)
220+
221+
json_payload = '{"blocks": [{"type": "text", "value": "hi"}]}'
222+
result = meta.pre_parse_json({"body": json_payload})
223+
assert result["body"] == json_payload
224+
225+
json_list_payload = '["hello", "world"]'
226+
result = meta.pre_parse_json({"body": json_list_payload})
227+
assert result["body"] == json_list_payload
228+
229+
# Plain strings and None are unaffected.
230+
result = meta.pre_parse_json({"body": "hello"})
231+
assert result["body"] == "hello"
232+
result = meta.pre_parse_json({"body": None})
233+
assert result["body"] is None
234+
235+
207236
def test_skip_names():
208237
"""Test that skipped parameters are not included in the model"""
209238

0 commit comments

Comments
 (0)