Repository: jlowin/fastmcp

Issue:
Unable to call tool with optional parameters
### Description

I've created a function with a handful of parameters - one of which is required: 
```python
def search_books(
    title_keywords: list[str],
    authors: list[str] | None = None,
    genres: list[str] | None = None,
    publication_years: list[int] | None = None,
    publishers: list[str] | None = None,
    is_in_print: bool = True,
    max_results: int = 10,
    sort_by: str | None = None
) -> list[Book]:
    """Search for books based on provided criteria."""
```

I've added this function as a tool, and I _am_ able to call this tool from my client when only using the `title_keywords` parameter. However, when my client uses the optional `authors` parameter I'm getting the following error message: 
```
Parameter 'authors' must be of type undefined, got array
```

And here is an example of the input that is being passed by my client: 
```
{
  "title_keywords": ["dragon", "fantasy"],
  "authors": ["George R.R. Martin", "J.R.R. Tolkien"]
}
```

As suggested in the documentation I've tried using a Pydantic `BaseModel` class as the input parameter, but I'm still getting the same error. My hunch is that this is something with the "input schema" validation that is being performed by FastMCP, but I'm not sure why the server is running into errors when validating these inputs, since it seems like this is something FastMCP should be able to handle. 

For reference, I was using Cursor as the client for the MCP server. 

### Example Code

```Python

```

### Version Information

```Text
FastMCP version:                                                              2.2.1
MCP version:                                                                  1.6.0
Python version:                                                              3.11.1
Platform:                                                macOS-15.4-arm64-arm-64bit
```

### Additional Context

_No response_


Discussion:
I setup a minimal reproduction
```
from fastmcp.server import FastMCP

mcp = FastMCP(name="Parent MCP Server")


@mcp.tool()
def search_books(
    title_keywords: list[str],
    authors: list[str] | None = None,
    genres: list[str] | None = None,
    publication_years: list[int] | None = None,
    publishers: list[str] | None = None,
    is_in_print: bool = True,
    max_results: int = 10,
    sort_by: str | None = None,
) -> list[str]:
    """Search for books based on provided criteria."""


if __name__ == "__main__":
    mcp.run(transport="sse")
```

And I do not get any error when providing an array for title keywords and an array for authors with MCP Inspector over SSE

How are you calling the tool?
Appreciate the prompt reply. Unfortunately calling the tool is a bit opaque. I'm using Cursor in "agent" mode, I'll ask a question, and then Cursor will use one of the MCP tools based on the prompt. Before proceeding with the MCP call I'll have to confirm that I _do_ in fact want to use the tool (and this is the point where I can inspect the input that will be passed). But doesn't seem like there's a great way to view logs or see exactly what Cursor is doing to invoke the tool. 
If its your MCP server you can start it i with debugger n sse mode and then point cursor at it over sse.

You can turn turn off "just my code" and drop a breakpoint in call_tool in FastMCP or just turn on break on user uncaught exceptions to see what's happening in the MCP server.

But honestly this looks like a problem with cursor that's happening outside of the MCP server based on the input schema that FastMCP is providing cursor
This looks to be an issue other people are having that is specific to cursor https://github.com/getcursor/cursor/issues/2932 or  https://github.com/basicmachines-co/basic-memory/issues/75
If I can find some time I'll try this with my server and cursor and report back
I'm having the same issue, it seems that I must omit any optional parameters to tool calls. "Error calling tool: Parameter 'q' must be of type undefined, got string" q is properly defined as an optional string in the mcp server code. I suspect it's something in either fastMCP or in cursor
Hi! I just found fastmcp 2.0, so awesome! FWIW, basic-memory is my app. For the issue referenced: https://github.com/basicmachines-co/basic-memory/issues/75. 

@strawgate I believe the issue was related to the tool expecting a pydantic object (json). When I changed the tool function to use plain args, Cursor seemed to be happier, even with some Optional values.  

I should note that I had to add copious usage docs for the LLM, and that may have helped also. 

https://github.com/basicmachines-co/basic-memory/blob/main/src/basic_memory/mcp/tools/search.py
Sounds like the growing consensus is this is a Cursor issue -- I pinged the Cursor team to see if they can give us any insight in to how Cursor agents might be formatting tool calls with optional params
I have this issue and it took me hours to track it down.
My api has calls which have optional prams, and FastMCP.from_openapi absolutely has this issue.
@mattjoyce I just hit this problem, I have all query params defined as optional, but still it is failing with 
`Input validation error: None is not of type 'string'`

This is my simple part of openapi 3.0.1 specs for which it is not working - (FYI @jlowin )

```
"/api/v1/employees": {
      "get": {
        "tags": [
          "employees API"
        ],
        "summary": "Get Employeed",
        "operationId": "fetchEmlpoyees_1",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "The maximum number of employees to return in a single paged request",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          },
          {
            "name": "pageToken",
            "in": "query",
            "description": "A limit to optimize a subsequent request for a page of results",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filter",
            "in": "query",
            "description": "Filter results using (operator_name,attribute_name,attribute_value) syntax",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "description": "Sort results using (attribute,attribute_name,attribute_sort_order) syntax",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "appIdentifier",
            "in": "query",
            "description": "App Identifier. Example: dep1",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-BP-SESSIONCONTEXT-LOCALE",
            "in": "header",
            "description": "Locale value",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-BP-SESSIONCONTEXT-DATETIMEPATTERN",
            "in": "header",
            "description": "Date time pattern",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-BP-SESSIONCONTEXT-USERTIMEZONE",
            "in": "header",
            "description": "User time zone value",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "BP-ACTIVITY-ID",
            "in": "header",
            "description": "activity id",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "BP-ACTIVITY-PARENT-ID",
            "in": "header",
            "description": "activity parent id",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "BP-TRANSACTION-ID",
            "in": "header",
            "description": "transaction id",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/EnumerationVO"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    }
```

I am using fastmcp - 2.10.5 (very latest)





Patch:
diff --git a/src/fastmcp/utilities/openapi.py b/src/fastmcp/utilities/openapi.py
index 830fc655e..f0cb2ea03 100644
--- a/src/fastmcp/utilities/openapi.py
+++ b/src/fastmcp/utilities/openapi.py
@@ -1095,6 +1095,35 @@ def _replace_ref_with_defs(
     return schema
 
 
+def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
+    """
+    Make an optional parameter schema nullable to allow None values.
+
+    For optional parameters, we need to allow null values in addition to the
+    specified type to handle cases where None is passed for optional parameters.
+    """
+    # If schema already has multiple types or is already nullable, don't modify
+    if "anyOf" in schema or "oneOf" in schema or "allOf" in schema:
+        return schema
+
+    # If it's already nullable (type includes null), don't modify
+    if isinstance(schema.get("type"), list) and "null" in schema["type"]:
+        return schema
+
+    # Create a new schema that allows null in addition to the original type
+    if "type" in schema:
+        original_type = schema["type"]
+        if isinstance(original_type, str):
+            # Single type - make it a union with null
+            nullable_schema = schema.copy()
+            nullable_schema["anyOf"] = [{"type": original_type}, {"type": "null"}]
+            # Remove the original type since we're using anyOf
+            del nullable_schema["type"]
+            return nullable_schema
+
+    return schema
+
+
 def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
     """
     Combines parameter and request body schemas into a single schema.
@@ -1156,15 +1185,25 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
             else:
                 param_schema["description"] = location_desc
 
+            # Make optional parameters nullable to allow None values
+            if not param.required:
+                param_schema = _make_optional_parameter_nullable(param_schema)
+
             properties[suffixed_name] = param_schema
         else:
             # No collision, use original name
             if param.required:
                 required.append(param.name)
-            properties[param.name] = _replace_ref_with_defs(
+            param_schema = _replace_ref_with_defs(
                 param.schema_.copy(), param.description
             )
 
+            # Make optional parameters nullable to allow None values
+            if not param.required:
+                param_schema = _make_optional_parameter_nullable(param_schema)
+
+            properties[param.name] = param_schema
+
     # Add request body properties (no suffixes for body parameters)
     if route.request_body and route.request_body.content_schema:
         for prop_name, prop_schema in body_props.items():


Test patch:
diff --git a/tests/server/openapi/test_optional_parameters.py b/tests/server/openapi/test_optional_parameters.py
new file mode 100644
index 000000000..fda714d9c
--- /dev/null
+++ b/tests/server/openapi/test_optional_parameters.py
@@ -0,0 +1,102 @@
+"""Test for optional parameter handling in FastMCP OpenAPI integration."""
+
+import pytest
+
+from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo, _combine_schemas
+
+
+async def test_optional_parameter_schema_allows_null():
+    """Test that optional parameters generate schemas that allow null values."""
+    # Create a minimal HTTPRoute with optional parameter
+    optional_param = ParameterInfo(
+        name="optional_param",
+        location="query",
+        required=False,
+        schema={"type": "string"},
+        description="Optional parameter",
+    )
+
+    required_param = ParameterInfo(
+        name="required_param",
+        location="query",
+        required=True,
+        schema={"type": "string"},
+        description="Required parameter",
+    )
+
+    route = HTTPRoute(
+        method="GET",
+        path="/test",
+        parameters=[required_param, optional_param],
+        request_body=None,
+        responses={},
+        summary="Test endpoint",
+        description=None,
+        schema_definitions={},
+    )
+
+    # Generate combined schema
+    schema = _combine_schemas(route)
+
+    # Verify that optional parameter allows null values
+    optional_param_schema = schema["properties"]["optional_param"]
+
+    # Should have anyOf with string and null types
+    assert "anyOf" in optional_param_schema
+    assert {"type": "string"} in optional_param_schema["anyOf"]
+    assert {"type": "null"} in optional_param_schema["anyOf"]
+
+    # Required parameter should not allow null
+    required_param_schema = schema["properties"]["required_param"]
+    assert required_param_schema["type"] == "string"
+    assert "anyOf" not in required_param_schema
+
+    # Required list should only contain required param
+    assert "required_param" in schema["required"]
+    assert "optional_param" not in schema["required"]
+
+
+@pytest.mark.parametrize(
+    "param_schema",
+    [
+        {"type": "string"},
+        {"type": "integer"},
+        {"type": "number"},
+        {"type": "boolean"},
+        {"type": "array", "items": {"type": "string"}},
+        {"type": "object", "properties": {"name": {"type": "string"}}},
+    ],
+)
+async def test_optional_parameter_allows_null_for_type(param_schema):
+    """Test that optional parameters of any type allow null values."""
+    optional_param = ParameterInfo(
+        name="optional_param",
+        location="query",
+        required=False,
+        schema=param_schema,
+        description="Optional parameter",
+    )
+
+    route = HTTPRoute(
+        method="GET",
+        path="/test",
+        parameters=[optional_param],
+        request_body=None,
+        responses={},
+        summary="Test endpoint",
+        description=None,
+        schema_definitions={},
+    )
+
+    # Generate combined schema
+    schema = _combine_schemas(route)
+    optional_param_schema = schema["properties"]["optional_param"]
+
+    # Should have anyOf with the original type and null
+    assert "anyOf" in optional_param_schema
+    assert {"type": "null"} in optional_param_schema["anyOf"]
+    # Check that original schema is preserved (either simple type or complex schema)
+    if "type" in param_schema:
+        assert {"type": param_schema["type"]} in optional_param_schema["anyOf"]
+    else:
+        assert param_schema in optional_param_schema["anyOf"]