diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index cc05c2692..e8e4e21c6 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -231,14 +231,60 @@ class OpenAPITool(Tool): path = path.replace(f"{{{param_name}}}", str(param_value)) # Prepare query parameters - filter out None and empty strings - query_params = { - p.name: kwargs.get(p.name) - for p in self._route.parameters - if p.location == "query" - and p.name in kwargs - and kwargs.get(p.name) is not None - and kwargs.get(p.name) != "" - } + query_params = {} + for p in self._route.parameters: + if ( + p.location == "query" + and p.name in kwargs + and kwargs.get(p.name) is not None + and kwargs.get(p.name) != "" + ): + param_value = kwargs.get(p.name) + + # Format array query parameters as comma-separated strings + # following OpenAPI form style (default for query parameters) + if isinstance(param_value, list) and p.schema_.get("type") == "array": + # Get explode parameter from schema, default is True for query parameters + # If explode is True, the array is serialized as separate parameters + # If explode is False, the array is serialized as a comma-separated string + explode = p.schema_.get("explode", True) + + if explode: + # When explode=True, we pass the array directly, which HTTPX will serialize + # as multiple parameters with the same name + query_params[p.name] = param_value + else: + # For arrays of simple types (strings, numbers, etc.), join with commas + if all( + isinstance(item, str | int | float | bool) + for item in param_value + ): + query_params[p.name] = ",".join(str(v) for v in param_value) + else: + # For complex types, try to create a simpler representation + try: + # Try to create a simple string representation + formatted_parts = [] + for item in param_value: + if isinstance(item, dict): + # For objects, serialize key-value pairs + item_parts = [] + for k, v in item.items(): + item_parts.append(f"{k}:{v}") + formatted_parts.append(".".join(item_parts)) + else: + formatted_parts.append(str(item)) + + query_params[p.name] = ",".join(formatted_parts) + except Exception as e: + logger.warning( + f"Failed to format complex array query parameter '{p.name}': {e}" + ) + # Fallback to string representation + query_params[p.name] = param_value + else: + # Non-array parameters are passed as is + query_params[p.name] = param_value # Prepare headers - fix typing by ensuring all values are strings headers = {} diff --git a/tests/server/test_openapi_array_params.py b/tests/server/test_openapi_array_params.py deleted file mode 100644 index b87149038..000000000 --- a/tests/server/test_openapi_array_params.py +++ /dev/null @@ -1,90 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import httpx -import pytest - -from fastmcp.server.openapi import OpenAPITool -from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo - - -@pytest.fixture -def mock_client(): - """Create a mock httpx.AsyncClient.""" - client = AsyncMock(spec=httpx.AsyncClient) - # Set up a mock response - mock_response = MagicMock() - mock_response.json.return_value = {"result": "success"} - mock_response.raise_for_status.return_value = None - client.request.return_value = mock_response - return client - - -@pytest.mark.asyncio -async def test_array_path_parameter_handling(mock_client): - """Test how array path parameters are handled.""" - # Create a simple route with array path parameter - route = HTTPRoute( - path="/select/{days}", - method="PUT", - operation_id="test-operation", - parameters=[ - ParameterInfo( - name="days", - location="path", - required=True, - schema={ - "type": "array", - "items": { - "type": "string", - "enum": [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", - ], - }, - }, - ) - ], - ) - - # Create the tool - tool = OpenAPITool( - client=mock_client, - route=route, - name="test-operation", - description="Test operation", - parameters={}, - ) - - # Test with a single value - await tool._execute_request(days=["monday"]) - - # Check that the path parameter is formatted correctly - # This is where the bug is: it should be '/select/monday' not '/select/[\'monday\']' - mock_client.request.assert_called_with( - method="PUT", - url="/select/monday", # This is the expected format - params={}, - headers={}, - json=None, - timeout=None, - ) - mock_client.request.reset_mock() - - # Test with multiple values - await tool._execute_request(days=["monday", "tuesday"]) - - # Check that the path parameter is formatted correctly - # It should be '/select/monday,tuesday' not '/select/[\'monday\', \'tuesday\']' - mock_client.request.assert_called_with( - method="PUT", - url="/select/monday,tuesday", # This is the expected format - params={}, - headers={}, - json=None, - timeout=None, - ) diff --git a/tests/server/test_openapi_path_parameters.py b/tests/server/test_openapi_path_parameters.py index 0e59aad8a..e8b6121e5 100644 --- a/tests/server/test_openapi_path_parameters.py +++ b/tests/server/test_openapi_path_parameters.py @@ -1,10 +1,12 @@ +from typing import Annotated, Literal from unittest.mock import AsyncMock, MagicMock import httpx import pytest +from fastapi import FastAPI, Query -from fastmcp import FastMCP -from fastmcp.server.openapi import OpenAPITool +from fastmcp import Client, FastMCP +from fastmcp.server.openapi import OpenAPITool, RouteMap, RouteType from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo @@ -258,3 +260,206 @@ async def test_complex_nested_array_path_parameter(mock_client): assert "active" in called_url, "The URL should contain filter values" assert "}" not in called_url, "The URL should not contain Python object syntax" assert "{" not in called_url, "The URL should not contain Python object syntax" + + +@pytest.mark.asyncio +async def test_array_query_param_with_fastapi(): + """Test array query parameters using FastAPI and FastMCP.from_fastapi integration.""" + # Create a FastAPI app with a route that has an array query parameter + app = FastAPI() + + @app.get("/select") + async def select_days( + days: Annotated[ + list[ + Literal[ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ] + ], + Query(explode=True), + ], + ): # Using explode=True to get days=monday&days=tuesday format + return {"selected": days} + + # Create a FastMCP server from the FastAPI app + mcp = FastMCP.from_fastapi( + app, + route_maps=[ + RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL) + ], + ) + + # Test with the client + async with Client(mcp) as client: + # Get the actual tool name first + tools = await client.list_tools() + tool_names = [tool.name for tool in tools] + assert len(tool_names) == 1, ( + f"Expected one tool, got {len(tool_names)}: {tool_names}" + ) + tool_name = tool_names[0] + + # Single day + result = await client.call_tool(tool_name, {"days": ["monday"]}) + # Client returns TextContent objects, so parse the JSON + assert len(result) == 1 + assert result[0].type == "text" + import json + + result_data = json.loads(result[0].text) + assert result_data == {"selected": ["monday"]} + + # Multiple days + result = await client.call_tool(tool_name, {"days": ["monday", "tuesday"]}) + assert len(result) == 1 + assert result[0].type == "text" + result_data = json.loads(result[0].text) + assert result_data == {"selected": ["monday", "tuesday"]} + + +@pytest.mark.asyncio +async def test_array_query_parameter_format(mock_client): + """Test that array query parameters are formatted as comma-separated values when explode=False.""" + # Create a route with array query parameter + route = HTTPRoute( + path="/select", + method="GET", + operation_id="test-operation", + parameters=[ + ParameterInfo( + name="days", + location="query", # This is a query parameter + required=True, + schema={ + "type": "array", + "explode": False, # Set explode=False to test comma-separated formatting + "items": { + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ], + }, + }, + ) + ], + ) + + # Create the tool + tool = OpenAPITool( + client=mock_client, + route=route, + name="test-operation", + description="Test operation", + parameters={}, + ) + + # Test with a single value + await tool._execute_request(days=["monday"]) + + # Check that the query parameter is formatted correctly + mock_client.request.assert_called_with( + method="GET", + url="/select", + params={"days": "monday"}, # Should be formatted as a string, not a list + headers={}, + json=None, + timeout=None, + ) + mock_client.request.reset_mock() + + # Test with multiple values + await tool._execute_request(days=["monday", "tuesday"]) + + # Check that the query parameter is formatted correctly + # It should be 'days=monday,tuesday' not 'days=["monday","tuesday"]' + mock_client.request.assert_called_with( + method="GET", + url="/select", + params={"days": "monday,tuesday"}, # Should be comma-separated + headers={}, + json=None, + timeout=None, + ) + + +@pytest.mark.asyncio +async def test_array_query_parameter_exploded_format(mock_client): + """Test that array query parameters are formatted as separate parameters when explode=True.""" + # Create a route with array query parameter with explode=True (default) + route = HTTPRoute( + path="/select-exploded", + method="GET", + operation_id="test-exploded-operation", + parameters=[ + ParameterInfo( + name="days", + location="query", # This is a query parameter + required=True, + schema={ + "type": "array", + "explode": True, # Set explode=True for separate parameter serialization + "items": { + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ], + }, + }, + ) + ], + ) + + # Create the tool + tool = OpenAPITool( + client=mock_client, + route=route, + name="test-exploded-operation", + description="Test operation with exploded arrays", + parameters={}, + ) + + # Test with a single value + await tool._execute_request(days=["monday"]) + + # Check that the query parameter is formatted correctly + mock_client.request.assert_called_with( + method="GET", + url="/select-exploded", + params={"days": ["monday"]}, # Should be passed as a list for explode=True + headers={}, + json=None, + timeout=None, + ) + mock_client.request.reset_mock() + + # Test with multiple values + await tool._execute_request(days=["monday", "tuesday"]) + + # Check that the query parameter is formatted correctly + # It should be passed as an array, which httpx will serialize as days=monday&days=tuesday + mock_client.request.assert_called_with( + method="GET", + url="/select-exploded", + params={"days": ["monday", "tuesday"]}, # Should be passed as a list + headers={}, + json=None, + timeout=None, + )