From 9b499c7559535b032a9a2b51bf2c8c17e6a35ca8 Mon Sep 17 00:00:00 2001 From: pragnyanramtha Date: Sat, 16 May 2026 16:40:08 +0000 Subject: [PATCH] fix: preserve optional string JSON arguments --- .../mcpserver/utilities/func_metadata.py | 16 +++++- tests/server/mcpserver/test_func_metadata.py | 53 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 4a76106371..3a46dc3681 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -148,7 +148,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: @@ -167,6 +167,20 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: ) +_SIMPLE_PRE_PARSE_TYPES = {str, int, float, bool, bytes, type(None)} + + +def _should_pre_parse_json(annotation: Any) -> bool: + """Return whether string inputs for an annotation should be JSON-decoded.""" + if annotation in _SIMPLE_PRE_PARSE_TYPES: + return False + + if is_union_origin(get_origin(annotation)): + return not all(arg in _SIMPLE_PRE_PARSE_TYPES for arg in get_args(annotation)) + + return True + + def func_metadata( func: Callable[..., Any], skip_names: Sequence[str] = (), diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index c57d1ee9f0..cc9a983833 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -182,6 +182,59 @@ def func_with_str_types(str_or_list: str | list[str]): # pragma: no cover assert result["str_or_list"] == ["hello", "world"] +def test_optional_str_preserves_json_strings(): + """String-like unions should preserve JSON-looking strings as strings.""" + + def func_optional_str(config: str | None = None): + return config + + meta = func_metadata(func_optional_str) + + json_obj_str = '{"database": "postgres", "port": 5432}' + result = meta.pre_parse_json({"config": json_obj_str}) + assert result["config"] == json_obj_str + assert func_optional_str(result["config"]) == json_obj_str + + json_array_str = '["item1", "item2", "item3"]' + result = meta.pre_parse_json({"config": json_array_str}) + assert result["config"] == json_array_str + assert func_optional_str(result["config"]) == json_array_str + + +@pytest.mark.anyio +async def test_optional_str_runtime_validation_preserves_json_string(): + """Ensure optional string values reach the function without JSON pre-parsing.""" + + def handle_json_payload(payload: str | None = None) -> str: + assert isinstance(payload, str) + return payload + + meta = func_metadata(handle_json_payload) + json_payload = '{"action": "create", "resource": "user"}' + + result = await meta.call_fn_with_arg_validation( + handle_json_payload, + fn_is_async=False, + arguments_to_validate={"payload": json_payload}, + arguments_to_pass_directly=None, + ) + + assert result == json_payload + + +def test_optional_list_still_pre_parses_json_string(): + """Complex optional types still accept JSON-encoded structured values.""" + + def func_optional_list(items: list[str] | None = None): + return items + + meta = func_metadata(func_optional_list) + + result = meta.pre_parse_json({"items": '["hello", "world"]'}) + assert result["items"] == ["hello", "world"] + assert func_optional_list(result["items"]) == ["hello", "world"] + + def test_skip_names(): """Test that skipped parameters are not included in the model"""