Skip to content

Commit 9b499c7

Browse files
fix: preserve optional string JSON arguments
1 parent 161834d commit 9b499c7

2 files changed

Lines changed: 68 additions & 1 deletion

File tree

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
148148
continue
149149

150150
field_info = key_to_field_info[data_key]
151-
if isinstance(data_value, str) and field_info.annotation is not str:
151+
if isinstance(data_value, str) and _should_pre_parse_json(field_info.annotation):
152152
try:
153153
pre_parsed = json.loads(data_value)
154154
except json.JSONDecodeError:
@@ -167,6 +167,20 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
167167
)
168168

169169

170+
_SIMPLE_PRE_PARSE_TYPES = {str, int, float, bool, bytes, type(None)}
171+
172+
173+
def _should_pre_parse_json(annotation: Any) -> bool:
174+
"""Return whether string inputs for an annotation should be JSON-decoded."""
175+
if annotation in _SIMPLE_PRE_PARSE_TYPES:
176+
return False
177+
178+
if is_union_origin(get_origin(annotation)):
179+
return not all(arg in _SIMPLE_PRE_PARSE_TYPES for arg in get_args(annotation))
180+
181+
return True
182+
183+
170184
def func_metadata(
171185
func: Callable[..., Any],
172186
skip_names: Sequence[str] = (),

tests/server/mcpserver/test_func_metadata.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,59 @@ def func_with_str_types(str_or_list: str | list[str]): # pragma: no cover
182182
assert result["str_or_list"] == ["hello", "world"]
183183

184184

185+
def test_optional_str_preserves_json_strings():
186+
"""String-like unions should preserve JSON-looking strings as strings."""
187+
188+
def func_optional_str(config: str | None = None):
189+
return config
190+
191+
meta = func_metadata(func_optional_str)
192+
193+
json_obj_str = '{"database": "postgres", "port": 5432}'
194+
result = meta.pre_parse_json({"config": json_obj_str})
195+
assert result["config"] == json_obj_str
196+
assert func_optional_str(result["config"]) == json_obj_str
197+
198+
json_array_str = '["item1", "item2", "item3"]'
199+
result = meta.pre_parse_json({"config": json_array_str})
200+
assert result["config"] == json_array_str
201+
assert func_optional_str(result["config"]) == json_array_str
202+
203+
204+
@pytest.mark.anyio
205+
async def test_optional_str_runtime_validation_preserves_json_string():
206+
"""Ensure optional string values reach the function without JSON pre-parsing."""
207+
208+
def handle_json_payload(payload: str | None = None) -> str:
209+
assert isinstance(payload, str)
210+
return payload
211+
212+
meta = func_metadata(handle_json_payload)
213+
json_payload = '{"action": "create", "resource": "user"}'
214+
215+
result = await meta.call_fn_with_arg_validation(
216+
handle_json_payload,
217+
fn_is_async=False,
218+
arguments_to_validate={"payload": json_payload},
219+
arguments_to_pass_directly=None,
220+
)
221+
222+
assert result == json_payload
223+
224+
225+
def test_optional_list_still_pre_parses_json_string():
226+
"""Complex optional types still accept JSON-encoded structured values."""
227+
228+
def func_optional_list(items: list[str] | None = None):
229+
return items
230+
231+
meta = func_metadata(func_optional_list)
232+
233+
result = meta.pre_parse_json({"items": '["hello", "world"]'})
234+
assert result["items"] == ["hello", "world"]
235+
assert func_optional_list(result["items"]) == ["hello", "world"]
236+
237+
185238
def test_skip_names():
186239
"""Test that skipped parameters are not included in the model"""
187240

0 commit comments

Comments
 (0)