From 35a506eb45c805c86d2460ae8dd66cc00624b122 Mon Sep 17 00:00:00 2001 From: adityuhkapoor Date: Tue, 10 Feb 2026 23:02:32 -0500 Subject: [PATCH] fix: skip JSON pre-parsing for str union annotations in pre_parse_json Github-Issue: #1873 Reported-by: TommyVee --- .../mcpserver/utilities/func_metadata.py | 14 ++++++++++- tests/server/mcpserver/test_func_metadata.py | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 4b539ce1f..0d6b9abc2 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -149,7 +149,7 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: continue field_info = key_to_field_info[data_key] - if isinstance(data_value, str) and field_info.annotation is not str: + if isinstance(data_value, str) and _should_pre_parse_json(field_info.annotation): try: pre_parsed = json.loads(data_value) except json.JSONDecodeError: @@ -420,6 +420,18 @@ def _try_create_model_and_schema( _no_default = object() +_SIMPLE_TYPES: frozenset[type] = frozenset({str, int, float, bool, type(None)}) + + +def _should_pre_parse_json(annotation: Any) -> bool: + """Return True if the annotation requires JSON pre-parsing.""" + if annotation is str: + return False + origin = get_origin(annotation) + if origin is not None and is_union_origin(origin): + return any(arg not in _SIMPLE_TYPES for arg in get_args(annotation)) + return True + def _create_model_from_class(cls: type[Any], type_hints: dict[str, Any]) -> type[BaseModel]: """Create a Pydantic model from an ordinary class. diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index c57d1ee9f..ac4881271 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -551,6 +551,31 @@ def handle_json_payload(payload: str, strict_mode: bool = False) -> str: assert result == f"Handled payload of length {len(json_array_payload)}" +def test_str_union_pre_parse_json(): + """Regression test for #1873: pre_parse_json should not JSON-parse strings + when the annotation is a union of simple types like str | None. + """ + + def func_optional_str(value: str | None = None) -> str: # pragma: no cover + return str(value) + + meta = func_metadata(func_optional_str) + + # str | None: JSON object/array strings should be preserved, not parsed + json_obj = '{"database": "postgres", "port": 5432}' + assert meta.pre_parse_json({"value": json_obj})["value"] == json_obj + + json_array = '["item1", "item2"]' + assert meta.pre_parse_json({"value": json_array})["value"] == json_array + + # Complex unions like list[str] | None should still pre-parse + def func_optional_list(items: list[str] | None = None) -> str: # pragma: no cover + return str(items) + + meta_list = func_metadata(func_optional_list) + assert meta_list.pre_parse_json({"items": '["a", "b", "c"]'})["items"] == ["a", "b", "c"] + + # Tests for structured output functionality