Skip to content

Fastmcp tool parameter parsing type error #381

Closed as not planned
Closed as not planned

Description

@weester-yan

When I define the tool parameter as string, the parameter parsing function of fastmcp parses the numeric string I pass in into a numeric value.

  • mcp/server/fastmcp/utilities/func_metadata.py
    def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
        """Pre-parse data from JSON.

        Return a dict with same keys as input but with values parsed from JSON
        if appropriate.

        This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside
        a string rather than an actual list. Claude desktop is prone to this - in fact
        it seems incapable of NOT doing this. For sub-models, it tends to pass
        dicts (JSON objects) as JSON strings, which can be pre-parsed here.
        """
        new_data = data.copy()  # Shallow copy
        for field_name, field_info in self.arg_model.model_fields.items():
            import logging
            logging.info((field_name, field_info))
            logging.info("before parse: ", new_data)
            if field_name not in data.keys():
                continue
            if isinstance(data[field_name], str):
                try:
                    pre_parsed = json.loads(data[field_name])
                except json.JSONDecodeError:
                    continue  # Not JSON - skip
                if isinstance(pre_parsed, str):
                    # 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.
                    continue
                new_data[field_name] = pre_parsed
             logging.info("after parse: ", new_data)
        assert new_data.keys() == data.keys()
        return new_data

test output:

video-1  | ('cid', FieldInfo(annotation=str, required=True, description='xxx.'))
video-1  | before parse: {'cid': '1.2'}
video-1  | after parse: {'cid': 1.2}
video-1  | Error calling tool: Error executing tool getinfo: 1 validation error for getinfoArguments
video-1  | cid
video-1  |   Input should be a valid string [type=string_type, input_value=1.2, input_type=float]
video-1  |     For further information visit https://errors.pydantic.dev/2.10/v/string_type

Here, json.loads will parse the incoming string "1.2" into the floating point number 1.2

So, when I use pydantic to define parameters in tool, a validation error occurs

from pydantic import validate_call, Field

@server.tool(description="xxx")
@validate_call
async def getinfo(
        cid: str = Field(..., description="xxx."),
) -> bool | None:

Is my understanding correct?

Activity

  1. msamon-nrg commented on Mar 27, 2025

    @msamon-nrg

    I experienced this same issue with this function signature:

    def tool(account_id: str):
        ...

    It was resolved once I updated it to def tool(account_id: int). I think you've identified the origin of the bug correctly. I believe what's happening is the json.loads(data[field_name]) is converting number strings to numbers in cases where data[field_name] is a number string. This is expected behavior of the json.loads method. For example:

    >>> import json
    >>> json.loads("1.2")
    1.2
  2. dsp-ant commented on Mar 27, 2025

    @dsp-ant
    Member

    Can you add more information, such as the version of the SDK?

  3. weester-yan commented on Mar 28, 2025

    @weester-yan
    Author

    I'm using version 1.3.0, but I saw that the latest version seems to have fixed this problem.

    def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
            """Pre-parse data from JSON.
    
            Return a dict with same keys as input but with values parsed from JSON
            if appropriate.
    
            This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside
            a string rather than an actual list. Claude desktop is prone to this - in fact
            it seems incapable of NOT doing this. For sub-models, it tends to pass
            dicts (JSON objects) as JSON strings, which can be pre-parsed here.
            """
            new_data = data.copy()  # Shallow copy
            for field_name, _field_info in self.arg_model.model_fields.items():
                if field_name not in data.keys():
                    continue
                if isinstance(data[field_name], str):
                    try:
                        pre_parsed = json.loads(data[field_name])
                    except json.JSONDecodeError:
                        continue  # Not JSON - skip
                    ___________________________
                    if isinstance(pre_parsed, str | int | float):
                        # 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.
                        continue
                    ___________________________
                    new_data[field_name] = pre_parsed
            assert new_data.keys() == data.keys()
            return new_data
    

    Thank you very much for your reply.

  4. davidlin2k commented on Mar 30, 2025

    @davidlin2k

    I suspect the problem is coming from this, I've also encountered similar problem

    https://docs.pydantic.dev/latest/concepts/strict_mode/

  5. davidlin2k commented on Mar 30, 2025

    @davidlin2k

    Seems like there is a PR trying to address this

    #350

  6. 0kenx commented on Apr 1, 2025

    @0kenx

    I can confirm the same bug. When client tries to call a functon of arg str and with a valid JSON string, it produces error:

    2025-04-01T18:23:11.591Z [filesystem] [info] Message from client: {"method":"tools/call","params":{"name":"write_file","arguments":{"path":"t.json","content":"{\"id\": 7392, \"name\": \"RandomProject\", \"version\": \"1.2.3\", \"isActive\": true, \"metadata\": {\"createdAt\": \"2024-04-01T12:34:56Z\", \"tags\": [\"random\", \"test\", \"example\"], \"complexity\": 5.7}, \"users\": [{\"userId\": 1001, \"username\": \"john_doe\", \"email\": \"john.doe@example.com\"}, {\"userId\": 1002, \"username\": \"jane_smith\", \"email\": \"jane.smith@example.com\"}], \"permissions\": {\"read\": true, \"write\": false, \"delete\": false}}"}},"jsonrpc":"2.0","id":20}
    2025-04-01 18:23:11,595 - mcp.server.lowlevel.server - INFO - Processing request of type CallToolRequest
    2025-04-01T18:23:11.602Z [filesystem] [info] Message from server: {"jsonrpc":"2.0","id":20,"result":{"content":[{"type":"text","text":"Error executing tool write_file: 1 validation error for write_fileArguments\ncontent\n  Input should be a valid string [type=string_type, input_value={'id': 7392, 'name': 'Ran...False, 'delete': False}}, input_type=dict]\n    For further information visit https://errors.pydantic.dev/2.10/v/string_type"}],"isError":true}}
    
  7. timesler commented on Apr 8, 2025

    @timesler
    Contributor

    We are seeing the same issue as @0kenx with JSON strings being converted to dicts when they need to stay as strings. Currently, the only workaround we can think of is to prepend something to the argument that makes it unparsable by json.loads and then remove that in the MCP server code (we are seeing this in a server we also developed, so this is possible)

  8. timesler commented on Apr 19, 2025

    @timesler
    Contributor

    I believe this would be fixed with #350. @dsp-ant would it be possible for that PR to get a look from maintainers?

  9. 0kenx commented on May 20, 2025

    @0kenx

    Is anyone maintaining this repo? @ihrpr

  10. added this to the r-05-25 milestone on May 20, 2025
  11. added
    bugSomething isn't working
    ready for workEnough information for someone to start working on
    P1Significant bug affecting many users, highly requested feature
    on Oct 3, 2025
  12. ksafonov commented on Dec 22, 2025

    @ksafonov

    JSON strings being converted to dicts when they need to stay as strings

    Same here using version 1.10.1.

  13. maxisbey commented on Dec 31, 2025

    @maxisbey
    Contributor

    Hi, PR #1113 (merged July 2025) should have fixed this for simple str annotations. Could you provide a minimal reproduction with your exact type annotation and SDK version? This will help us identify if there are edge cases we missed.

    AI Disclaimer

  14. Nuclear2 commented on Jan 8, 2026

    @Nuclear2

    Still in sdk 1.21.0,tool definition in server is:

    @mcp.tool()
    def add(a: int, b: int, c: Optional[str]):
        """Add two numbers"""
        print(type(a))
        print(type(b))
        print(type(c))
        return a + b
    

    When invoking this tool with a valid json string value for the argument c, the function receives c as a dict instead of a str, resulting in a validation error.
    This appears to happen whenever an argument is annotated with a union type—such as Optional[str] (from typing) or str | None. In these cases, the MCP framework seems to interpret the parameter as a Union or types.Optional type, which it then incorrectly deserializes as a dictionary rather than handling it as a nullable string.

        def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
            """Pre-parse data from JSON.
    
            Return a dict with same keys as input but with values parsed from JSON
            if appropriate.
    
            This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside
            a string rather than an actual list. Claude desktop is prone to this - in fact
            it seems incapable of NOT doing this. For sub-models, it tends to pass
            dicts (JSON objects) as JSON strings, which can be pre-parsed here.
            """
            new_data = data.copy()  # Shallow copy
    
            # Build a mapping from input keys (including aliases) to field info
            key_to_field_info: dict[str, FieldInfo] = {}
            for field_name, field_info in self.arg_model.model_fields.items():
                # Map both the field name and its alias (if any) to the field info
                key_to_field_info[field_name] = field_info
                if field_info.alias:
                    key_to_field_info[field_info.alias] = field_info
    
            for data_key, data_value in data.items():
                if data_key not in key_to_field_info:  # pragma: no cover
                    continue
    
                field_info = key_to_field_info[data_key]
    ##########################################################################################
                if isinstance(data_value, str) and field_info.annotation is not str:
                # filed_info.annotation is Union(str, NoneType) or types.Optional[str] rather than str
                # It then causes the string to be parsed as a dict via json.loads()
    ##########################################################################################
                    try:
                        pre_parsed = json.loads(data_value)
                    except json.JSONDecodeError:
                        continue  # Not JSON - skip
                    if isinstance(pre_parsed, str | int | float):
                        # 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.
                        continue
                    new_data[data_key] = pre_parsed
            assert new_data.keys() == data.keys()
            return new_data
    

    It might be better to handle this as follows:

    import typing
    from typing import get_origin, get_args
    
    def _is_string_like_type(annotation) -> bool:
        """Check if the annotation accepts `str` as a valid type (including in unions)."""
        # Handle direct str
        if annotation is str:
            return True
        
        # Handle Optional[str], Union[str, None], str | None, etc.
        origin = get_origin(annotation)
        if origin is typing.Union or (hasattr(types, "UnionType") and isinstance(annotation, types.UnionType)):
            args = get_args(annotation)
            return str in args
        
        # Handle other cases like Annotated[str, ...]
        if origin is not None and hasattr(annotation, "__origin__"):
            return _is_string_like_type(annotation.__origin__)
        
        return False
    

    Then replace the field_info.annotation is not str check with _is_string_like_type(annotation).

  15. maxisbey commented on Jul 24, 2026

    @maxisbey
    Contributor

    Thanks for the report. Tracking this in #1873 instead. Closing this as part of a wider backlog cleanup around the v2 release. If it's still relevant, feel free to reopen.

    AI Disclaimer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Significant bug affecting many users, highly requested featurebugSomething isn't workingready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions