mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 23:29:10 +02:00
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>
258 lines
8.9 KiB
Python
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
|