fastmcp/tests/server/openapi/test_parameter_collisions.py
Jeremiah Lowin 5d95ddd8c0 Fix OpenAPI parameter name collisions with location suffixing
Resolves parameter conflicts when same name appears in path/query/header
and request body by adding double underscore suffixes (id__path, id__query).
Body parameters keep original names for natural REST API patterns.

Closes #1100

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 18:49:00 -04:00

258 lines
8.9 KiB
Python

"""Tests for handling parameter name collisions between different OpenAPI parameter locations."""
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from fastmcp.server.openapi import OpenAPITool
from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo, RequestBodyInfo
@pytest.fixture
def mock_client():
"""Create a mock httpx.AsyncClient."""
client = AsyncMock(spec=httpx.AsyncClient)
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
class TestParameterCollisions:
"""Test parameter name collisions between path/query/header and body parameters."""
async def test_path_body_collision_current_broken_behavior(self, mock_client):
"""
Demonstrates the current broken behavior when a parameter exists in both path and body.
This test should FAIL with the current implementation.
"""
# Create route with collision: id in both path and body
route = HTTPRoute(
path="/users/{id}",
method="PUT",
operation_id="update_user",
parameters=[
ParameterInfo(
name="id",
location="path",
required=True,
schema={"type": "integer"},
)
],
request_body=RequestBodyInfo(
content_schema={
"application/json": {
"type": "object",
"properties": {
"id": {"type": "integer", "description": "User ID"},
"name": {"type": "string", "description": "User name"},
"email": {"type": "string", "description": "User email"},
},
"required": ["id", "name"],
}
}
),
)
# Create tool with current implementation
tool = OpenAPITool(
client=mock_client,
route=route,
name="update_user",
description="Update user",
parameters={}, # Schema would be generated by _combine_schemas
)
# This call should work but currently fails because body 'id' is excluded
arguments = {"id": 123, "name": "John Doe", "email": "john@example.com"}
await tool.run(arguments)
# Check what was actually sent
call_args = mock_client.request.call_args
assert call_args is not None
# Current broken behavior: id goes to path but is excluded from body
# This means the body is missing the required 'id' field
assert call_args[1]["url"] == "/users/123" # Path parameter works
# This assertion will FAIL with current implementation because 'id' is excluded from body
expected_body = {"id": 123, "name": "John Doe", "email": "john@example.com"}
assert call_args[1]["json"] == expected_body, (
"Body should include 'id' parameter"
)
async def test_path_body_collision_with_suffixing(self, mock_client):
"""
Test the desired behavior with parameter suffixing.
This test should PASS after implementing the fix.
"""
# Create route with collision: id in both path and body
route = HTTPRoute(
path="/users/{id}",
method="PUT",
operation_id="update_user",
parameters=[
ParameterInfo(
name="id",
location="path",
required=True,
schema={"type": "integer"},
)
],
request_body=RequestBodyInfo(
content_schema={
"application/json": {
"type": "object",
"properties": {
"id": {"type": "integer", "description": "User ID"},
"name": {"type": "string", "description": "User name"},
"email": {"type": "string", "description": "User email"},
},
"required": ["id", "name"],
}
}
),
)
# Tool should be created with suffixed schema
tool = OpenAPITool(
client=mock_client,
route=route,
name="update_user",
description="Update user",
parameters={}, # Schema would include id__path and id
)
# LLM would call with suffixed parameters
arguments = {
"id__path": 123, # Goes to path parameter
"id": 123, # Goes to body parameter
"name": "John Doe",
"email": "john@example.com",
}
await tool.run(arguments)
# Verify correct request was made
call_args = mock_client.request.call_args
assert call_args is not None
# Path parameter should be populated from id__path
assert call_args[1]["url"] == "/users/123"
# Body should include id (from unsuffixed parameter)
expected_body = {"id": 123, "name": "John Doe", "email": "john@example.com"}
assert call_args[1]["json"] == expected_body
async def test_query_body_collision_with_suffixing(self, mock_client):
"""Test parameter collision between query and body parameters."""
route = HTTPRoute(
path="/search",
method="POST",
operation_id="search_users",
parameters=[
ParameterInfo(
name="limit",
location="query",
required=False,
schema={"type": "integer", "default": 10},
)
],
request_body=RequestBodyInfo(
content_schema={
"application/json": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Max results in response",
},
"query": {"type": "string", "description": "Search query"},
},
"required": ["query"],
}
}
),
)
tool = OpenAPITool(
client=mock_client,
route=route,
name="search_users",
description="Search users",
parameters={},
)
# LLM call with suffixed parameters
arguments = {
"limit__query": 5, # Goes to query parameter
"limit": 100, # Goes to body parameter
"query": "john",
}
await tool.run(arguments)
call_args = mock_client.request.call_args
assert call_args is not None
# Query parameter from limit__query
assert call_args[1]["params"] == {"limit": 5}
# Body includes limit from unsuffixed parameter
expected_body = {"limit": 100, "query": "john"}
assert call_args[1]["json"] == expected_body
async def test_no_collisions_unchanged_behavior(self, mock_client):
"""Test that parameters with no collisions keep original names."""
route = HTTPRoute(
path="/users/{user_id}",
method="POST",
operation_id="create_user",
parameters=[
ParameterInfo(
name="user_id",
location="path",
required=True,
schema={"type": "integer"},
)
],
request_body=RequestBodyInfo(
content_schema={
"application/json": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
},
"required": ["name"],
}
}
),
)
tool = OpenAPITool(
client=mock_client,
route=route,
name="create_user",
description="Create user",
parameters={},
)
# No collisions, so original parameter names should work
arguments = {
"user_id": 123, # Path parameter (no suffix needed)
"name": "John", # Body parameter
"email": "john@example.com",
}
await tool.run(arguments)
call_args = mock_client.request.call_args
assert call_args is not None
assert call_args[1]["url"] == "/users/123"
expected_body = {"name": "John", "email": "john@example.com"}
assert call_args[1]["json"] == expected_body