Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ def _is_input_required_type(obj: Any) -> bool:
_CONTENT_SEQUENCE_ORIGINS = (list, tuple, Sequence)


def _annotation_accepts_str(annotation: Any) -> bool:
"""Whether an annotation can accept a plain string value."""
if annotation is Any or annotation is str:
return True
origin = get_origin(annotation)
if origin is Annotated:
return _annotation_accepts_str(get_args(annotation)[0])
if is_union_origin(origin):
return any(_annotation_accepts_str(arg) for arg in get_args(annotation))
return False


def _returns_content(annotation: Any) -> bool:
"""Whether a return annotation declares content blocks or the `Image`/`Audio` helpers, bare or as
the items of a list/tuple or the arms of a union: the values `_convert_to_content` renders as blocks
Expand Down Expand Up @@ -258,10 +270,17 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
# Not JSON, or JSON the parser refuses (over-long integers, deep
# nesting): leave the string for validation to accept or reject.
continue
if isinstance(pre_parsed, str | int | float):
if isinstance(pre_parsed, str):
if not _annotation_accepts_str(field_info.annotation):
new_data[data_key] = pre_parsed
# This is likely that the raw value is e.g. `"hello"` which we
# Should really be parsed as '"hello"' in Python - but if we parse
# it as JSON it'll turn into just 'hello'. So we skip it.
# it as JSON it'll turn into just 'hello'. So we skip it for
# annotations that can already accept a string.
continue
if isinstance(pre_parsed, int | float):
# Pydantic can coerce numeric strings for numeric annotations; leaving
# the original string also avoids changing string-like union behavior.
continue
new_data[data_key] = pre_parsed
assert new_data.keys() == data.keys()
Expand Down
22 changes: 22 additions & 0 deletions tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
# pyright: reportMissingParameterType=false
# pyright: reportUnknownArgumentType=false
# pyright: reportUnknownLambdaType=false
import json
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated, Any, Final, NamedTuple, TypedDict
from uuid import UUID, uuid4

import annotated_types
import pytest
Expand Down Expand Up @@ -226,6 +228,26 @@ def func_with_str_types(str_or_list: str | list[str]): # pragma: no cover
assert result["str_or_list"] == ["hello", "world"]


@pytest.mark.anyio
async def test_json_string_is_parsed_for_uuid():
"""Test that JSON strings are parsed for non-string annotations like UUID."""

def func_with_uuid(value: UUID): # pragma: no cover
return value

meta = func_metadata(func_with_uuid)
value = uuid4()

result = await meta.call_fn(
func_with_uuid,
fn_is_async=False,
arguments=meta.validate_arguments({"value": json.dumps(str(value))}),
arguments_to_pass_directly=None,
)

assert result == value


def test_pre_parse_json_leaves_strings_the_json_parser_refuses_untouched():
"""A string json.loads rejects with something other than JSONDecodeError (an over-long integer,
nesting past the recursion limit) is left as-is for validation to reject, not raised."""
Expand Down
Loading