mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-26 23:44:17 +02:00
parent
18533951ad
commit
f8f05ca395
29 changed files with 26 additions and 78 deletions
|
|
@ -0,0 +1 @@
|
|||
"""Tests for openapi_new server components."""
|
||||
|
|
@ -0,0 +1,706 @@
|
|||
"""Comprehensive tests for OpenAPI new implementation."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import Response
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.experimental.server.openapi import FastMCPOpenAPI
|
||||
|
||||
|
||||
class TestOpenAPIComprehensive:
|
||||
"""Comprehensive tests ensuring no functionality is lost."""
|
||||
|
||||
@pytest.fixture
|
||||
def comprehensive_openapi_spec(self):
|
||||
"""Comprehensive OpenAPI spec covering all major features."""
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Comprehensive API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com"}],
|
||||
"components": {
|
||||
"schemas": {
|
||||
"User": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string", "format": "email"},
|
||||
"age": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
"required": ["name", "email"],
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "integer"},
|
||||
"message": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"parameters": {
|
||||
"UserId": {
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
"description": "User identifier",
|
||||
}
|
||||
},
|
||||
},
|
||||
"paths": {
|
||||
# Basic CRUD operations
|
||||
"/users": {
|
||||
"get": {
|
||||
"operationId": "list_users",
|
||||
"summary": "List all users",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
"description": "Number of users to return",
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
},
|
||||
"description": "Number of users to skip",
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["name", "email", "age"],
|
||||
},
|
||||
"description": "Sort field",
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of users",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/User"
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"post": {
|
||||
"operationId": "create_user",
|
||||
"summary": "Create a new user",
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/User"}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "User created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/User"}
|
||||
}
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/Error"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/users/{id}": {
|
||||
"parameters": [{"$ref": "#/components/parameters/UserId"}],
|
||||
"get": {
|
||||
"operationId": "get_user",
|
||||
"summary": "Get user by ID",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/User"}
|
||||
}
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/Error"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"put": {
|
||||
"operationId": "update_user",
|
||||
"summary": "Update user",
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/User"}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/User"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "delete_user",
|
||||
"summary": "Delete user",
|
||||
"responses": {
|
||||
"204": {"description": "User deleted"},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/Error"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
# Complex parameter scenarios
|
||||
"/search": {
|
||||
"get": {
|
||||
"operationId": "search_users",
|
||||
"summary": "Search users with complex filters",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
"description": "Search query",
|
||||
},
|
||||
{
|
||||
"name": "filter",
|
||||
"in": "query",
|
||||
"style": "deepObject",
|
||||
"explode": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "integer"},
|
||||
"max": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
"name": {"type": "string"},
|
||||
"active": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "X-Request-ID",
|
||||
"in": "header",
|
||||
"schema": {"type": "string"},
|
||||
"description": "Request identifier for tracing",
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Search results",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/User"
|
||||
},
|
||||
},
|
||||
"total": {"type": "integer"},
|
||||
"page": {"type": "integer"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
# Parameter collision scenario
|
||||
"/collision/{id}": {
|
||||
"patch": {
|
||||
"operationId": "collision_test",
|
||||
"summary": "Test parameter collision handling",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
"description": "Resource ID",
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"in": "query",
|
||||
"schema": {"type": "integer", "default": 1},
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"in": "header",
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
],
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Internal ID",
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Data version",
|
||||
},
|
||||
"data": {"type": "object"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {"200": {"description": "Updated"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def openapi_31_spec(self):
|
||||
"""OpenAPI 3.1 spec to test compatibility."""
|
||||
return {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "OpenAPI 3.1 Test", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/items/{id}": {
|
||||
"get": {
|
||||
"operationId": "get_item_31",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Item details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_comprehensive_server_initialization(
|
||||
self, comprehensive_openapi_spec
|
||||
):
|
||||
"""Test server initialization with comprehensive spec."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_openapi_spec,
|
||||
client=client,
|
||||
name="Comprehensive Test Server",
|
||||
)
|
||||
|
||||
# Should initialize successfully
|
||||
assert server.name == "Comprehensive Test Server"
|
||||
assert hasattr(server, "_director")
|
||||
assert hasattr(server, "_spec")
|
||||
|
||||
# Test with in-memory client
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Should have created tools for all operations
|
||||
tool_names = {tool.name for tool in tools}
|
||||
expected_operations = {
|
||||
"list_users",
|
||||
"create_user",
|
||||
"get_user",
|
||||
"update_user",
|
||||
"delete_user",
|
||||
"search_users",
|
||||
"collision_test",
|
||||
}
|
||||
|
||||
assert tool_names == expected_operations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openapi_31_compatibility(self, openapi_31_spec):
|
||||
"""Test that OpenAPI 3.1 specs work correctly."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=openapi_31_spec,
|
||||
client=client,
|
||||
name="OpenAPI 3.1 Test",
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
assert len(tools) == 1
|
||||
tool = tools[0]
|
||||
assert tool.name == "get_item_31"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parameter_collision_handling(self, comprehensive_openapi_spec):
|
||||
"""Test that parameter collisions are handled correctly."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_openapi_spec,
|
||||
client=client,
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
collision_tool = next(
|
||||
tool for tool in tools if tool.name == "collision_test"
|
||||
)
|
||||
schema = collision_tool.inputSchema
|
||||
properties = schema["properties"]
|
||||
|
||||
# Should have unique parameter names for colliding parameters
|
||||
param_names = list(properties.keys())
|
||||
|
||||
# Should have some form of id parameters (path and body)
|
||||
id_params = [name for name in param_names if "id" in name]
|
||||
assert len(id_params) >= 2
|
||||
|
||||
# Should have some form of version parameters (query, header, body)
|
||||
version_params = [name for name in param_names if "version" in name]
|
||||
assert len(version_params) >= 3
|
||||
|
||||
# Should have other parameters
|
||||
assert "data" in param_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deep_object_parameters(self, comprehensive_openapi_spec):
|
||||
"""Test deepObject parameter handling."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_openapi_spec,
|
||||
client=client,
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
search_tool = next(
|
||||
tool for tool in tools if tool.name == "search_users"
|
||||
)
|
||||
schema = search_tool.inputSchema
|
||||
properties = schema["properties"]
|
||||
|
||||
# Should have flattened deepObject parameters
|
||||
# The exact flattening depends on implementation
|
||||
assert "q" in properties # Regular query parameter
|
||||
|
||||
# Should have some form of filter parameters
|
||||
filter_params = [name for name in properties.keys() if "filter" in name]
|
||||
assert len(filter_params) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_building_and_execution(self, comprehensive_openapi_spec):
|
||||
"""Test that requests are built and executed correctly."""
|
||||
# Create a mock client that tracks requests
|
||||
mock_client = Mock(spec=httpx.AsyncClient)
|
||||
mock_client.base_url = "https://api.example.com"
|
||||
mock_client.headers = None
|
||||
|
||||
# Mock successful response
|
||||
mock_response = Mock(spec=Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": 123,
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
}
|
||||
mock_response.text = json.dumps(
|
||||
{"id": 123, "name": "Test User", "email": "test@example.com"}
|
||||
)
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
mock_client.send = AsyncMock(return_value=mock_response)
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_openapi_spec,
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
# Test GET request with path parameter
|
||||
await mcp_client.call_tool("get_user", {"id": 123})
|
||||
|
||||
# Should have made a request
|
||||
mock_client.send.assert_called_once()
|
||||
request = mock_client.send.call_args[0][0]
|
||||
|
||||
# Verify request details
|
||||
assert request.method == "GET"
|
||||
assert "123" in str(request.url)
|
||||
assert "users/123" in str(request.url)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complex_request_with_body_and_parameters(
|
||||
self, comprehensive_openapi_spec
|
||||
):
|
||||
"""Test complex request with both parameters and body."""
|
||||
mock_client = Mock(spec=httpx.AsyncClient)
|
||||
mock_client.base_url = "https://api.example.com"
|
||||
mock_client.headers = None
|
||||
|
||||
mock_response = Mock(spec=Response)
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {
|
||||
"id": 456,
|
||||
"name": "New User",
|
||||
"email": "new@example.com",
|
||||
}
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
mock_client.send = AsyncMock(return_value=mock_response)
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_openapi_spec,
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
# Test POST request with body
|
||||
await mcp_client.call_tool(
|
||||
"create_user",
|
||||
{
|
||||
"name": "New User",
|
||||
"email": "new@example.com",
|
||||
"age": 25,
|
||||
},
|
||||
)
|
||||
|
||||
# Should have made a request
|
||||
mock_client.send.assert_called_once()
|
||||
request = mock_client.send.call_args[0][0]
|
||||
|
||||
# Verify request details
|
||||
assert request.method == "POST"
|
||||
assert "users" in str(request.url)
|
||||
|
||||
# Should have JSON body
|
||||
assert request.content is not None
|
||||
body_data = json.loads(request.content)
|
||||
assert body_data["name"] == "New User"
|
||||
assert body_data["email"] == "new@example.com"
|
||||
assert body_data["age"] == 25
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_parameters(self, comprehensive_openapi_spec):
|
||||
"""Test query parameter handling."""
|
||||
mock_client = Mock(spec=httpx.AsyncClient)
|
||||
mock_client.base_url = "https://api.example.com"
|
||||
mock_client.headers = None
|
||||
|
||||
mock_response = Mock(spec=Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = []
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
mock_client.send = AsyncMock(return_value=mock_response)
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_openapi_spec,
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
# Test GET request with query parameters
|
||||
await mcp_client.call_tool(
|
||||
"list_users",
|
||||
{
|
||||
"limit": 20,
|
||||
"offset": 10,
|
||||
"sort": "name",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client.send.assert_called_once()
|
||||
request = mock_client.send.call_args[0][0]
|
||||
|
||||
# Verify query parameters in URL
|
||||
url_str = str(request.url)
|
||||
assert "limit=20" in url_str
|
||||
assert "offset=10" in url_str
|
||||
assert "sort=name" in url_str
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handling(self, comprehensive_openapi_spec):
|
||||
"""Test error handling for HTTP errors."""
|
||||
mock_client = Mock(spec=httpx.AsyncClient)
|
||||
mock_client.base_url = "https://api.example.com"
|
||||
mock_client.headers = None
|
||||
|
||||
# Mock HTTP error response
|
||||
mock_response = Mock(spec=Response)
|
||||
mock_response.status_code = 404
|
||||
mock_response.reason_phrase = "Not Found"
|
||||
mock_response.json.return_value = {"code": 404, "message": "User not found"}
|
||||
mock_response.text = json.dumps({"code": 404, "message": "User not found"})
|
||||
|
||||
# Configure raise_for_status to raise HTTPStatusError
|
||||
def raise_for_status():
|
||||
raise httpx.HTTPStatusError(
|
||||
"404 Not Found", request=Mock(), response=mock_response
|
||||
)
|
||||
|
||||
mock_response.raise_for_status = raise_for_status
|
||||
mock_client.send = AsyncMock(return_value=mock_response)
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_openapi_spec,
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
# Should handle HTTP errors gracefully
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await mcp_client.call_tool("get_user", {"id": 999})
|
||||
|
||||
# Error should be wrapped appropriately
|
||||
error_message = str(exc_info.value)
|
||||
assert "404" in error_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schema_refs_resolution(self, comprehensive_openapi_spec):
|
||||
"""Test that schema references are resolved correctly."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_openapi_spec,
|
||||
client=client,
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Find create_user tool which uses schema refs
|
||||
create_tool = next(tool for tool in tools if tool.name == "create_user")
|
||||
schema = create_tool.inputSchema
|
||||
properties = schema["properties"]
|
||||
|
||||
# Should have resolved User schema properties
|
||||
assert "name" in properties
|
||||
assert "email" in properties
|
||||
# May also have id and age depending on implementation
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_optional_vs_required_parameters(self, comprehensive_openapi_spec):
|
||||
"""Test handling of optional vs required parameters."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_openapi_spec,
|
||||
client=client,
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Check list_users tool - has optional query parameters
|
||||
list_tool = next(tool for tool in tools if tool.name == "list_users")
|
||||
schema = list_tool.inputSchema
|
||||
# Query parameters should be optional
|
||||
# (may not appear in required list)
|
||||
# This test just ensures the schema is well-formed
|
||||
assert "properties" in schema
|
||||
|
||||
# Check search_users tool - has required query parameter
|
||||
search_tool = next(
|
||||
tool for tool in tools if tool.name == "search_users"
|
||||
)
|
||||
search_schema = search_tool.inputSchema
|
||||
# Should have some required parameters
|
||||
assert len(search_schema["properties"]) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_performance_no_latency(self, comprehensive_openapi_spec):
|
||||
"""Test that server initialization is fast (no code generation latency)."""
|
||||
import time
|
||||
|
||||
# Time the server creation
|
||||
start_time = time.time()
|
||||
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_openapi_spec,
|
||||
client=client,
|
||||
)
|
||||
|
||||
end_time = time.time()
|
||||
|
||||
# Should be very fast (no code generation)
|
||||
initialization_time = end_time - start_time
|
||||
assert initialization_time < 0.1 # Should be under 100ms
|
||||
|
||||
# Verify server was created correctly
|
||||
assert server is not None
|
||||
assert hasattr(server, "_director")
|
||||
assert hasattr(server, "_spec")
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
"""Tests for deepObject style parameter handling in openapi_new."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.experimental.server.openapi import FastMCPOpenAPI
|
||||
|
||||
|
||||
class TestDeepObjectStyle:
|
||||
"""Test deepObject style parameter handling in openapi_new."""
|
||||
|
||||
@pytest.fixture
|
||||
def deepobject_spec(self):
|
||||
"""OpenAPI spec with deepObject style parameters."""
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "DeepObject Test API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com"}],
|
||||
"paths": {
|
||||
"/surveys": {
|
||||
"get": {
|
||||
"operationId": "get_surveys",
|
||||
"summary": "Get surveys with deepObject filtering",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "target",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"style": "deepObject",
|
||||
"explode": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Target ID",
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["location", "organisation"],
|
||||
"description": "Target type",
|
||||
},
|
||||
},
|
||||
"required": ["type", "id"],
|
||||
},
|
||||
"description": "Target object for filtering",
|
||||
},
|
||||
{
|
||||
"name": "filters",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"style": "deepObject",
|
||||
"explode": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string"},
|
||||
"category": {"type": "string"},
|
||||
"priority": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
"description": "Additional filters",
|
||||
},
|
||||
{
|
||||
"name": "compact",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"style": "deepObject",
|
||||
"explode": False,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"format": {"type": "string"},
|
||||
"level": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
"description": "Compact format options (explode=false)",
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Survey list",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"surveys": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"},
|
||||
},
|
||||
"total": {"type": "integer"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/users/{id}/preferences": {
|
||||
"patch": {
|
||||
"operationId": "update_preferences",
|
||||
"summary": "Update user preferences with deepObject in body",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"preferences": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme": {"type": "string"},
|
||||
"notifications": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"push": {"type": "boolean"},
|
||||
"frequency": {
|
||||
"type": "string"
|
||||
},
|
||||
},
|
||||
},
|
||||
"privacy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_visible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"analytics": {
|
||||
"type": "boolean"
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "Nested preference object",
|
||||
}
|
||||
},
|
||||
"required": ["preferences"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Preferences updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {"type": "boolean"}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepobject_style_parsing_from_spec(self, deepobject_spec):
|
||||
"""Test that deepObject style parameters are correctly parsed from OpenAPI spec."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=deepobject_spec,
|
||||
client=client,
|
||||
name="DeepObject Test Server",
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Find the surveys tool
|
||||
surveys_tool = next(
|
||||
tool for tool in tools if tool.name == "get_surveys"
|
||||
)
|
||||
assert surveys_tool is not None
|
||||
|
||||
# Check that deepObject parameters are included in schema
|
||||
params = surveys_tool.inputSchema
|
||||
properties = params["properties"]
|
||||
|
||||
# Should have the deepObject parameters
|
||||
assert "target" in properties
|
||||
assert "filters" in properties
|
||||
assert "compact" in properties
|
||||
|
||||
# Check that target parameter is present
|
||||
# (Exact schema structure may vary based on implementation)
|
||||
target_param = properties["target"]
|
||||
# Should have some structure, exact format may vary
|
||||
assert target_param is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepobject_explode_true_handling(self, deepobject_spec):
|
||||
"""Test deepObject with explode=true parameter handling."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=deepobject_spec,
|
||||
client=client,
|
||||
name="DeepObject Test Server",
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
surveys_tool = next(
|
||||
tool for tool in tools if tool.name == "get_surveys"
|
||||
)
|
||||
|
||||
# Check that explode=true parameters are properly structured
|
||||
params = surveys_tool.inputSchema
|
||||
properties = params["properties"]
|
||||
|
||||
# Target parameter with explode=true should allow individual property access
|
||||
target_properties = properties["target"]["properties"]
|
||||
assert "id" in target_properties
|
||||
assert "type" in target_properties
|
||||
assert target_properties["type"]["enum"] == ["location", "organisation"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepobject_explode_false_handling(self, deepobject_spec):
|
||||
"""Test deepObject with explode=false parameter handling."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=deepobject_spec,
|
||||
client=client,
|
||||
name="DeepObject Test Server",
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
surveys_tool = next(
|
||||
tool for tool in tools if tool.name == "get_surveys"
|
||||
)
|
||||
|
||||
# Check that explode=false parameters are handled
|
||||
params = surveys_tool.inputSchema
|
||||
properties = params["properties"]
|
||||
|
||||
# Compact parameter with explode=false should still be present and valid
|
||||
assert "compact" in properties
|
||||
compact_param = properties["compact"]
|
||||
# Check that it's a valid parameter (exact structure may vary)
|
||||
assert compact_param is not None
|
||||
# If it has a type, it should be object
|
||||
if "type" in compact_param:
|
||||
assert compact_param["type"] == "object"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_object_structure_in_request_body(self, deepobject_spec):
|
||||
"""Test nested object structures in request body are preserved."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=deepobject_spec,
|
||||
client=client,
|
||||
name="DeepObject Test Server",
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Find the preferences tool
|
||||
prefs_tool = next(
|
||||
tool for tool in tools if tool.name == "update_preferences"
|
||||
)
|
||||
assert prefs_tool is not None
|
||||
|
||||
# Check that nested object structure is preserved
|
||||
params = prefs_tool.inputSchema
|
||||
properties = params["properties"]
|
||||
|
||||
# Should have path parameter
|
||||
assert "id" in properties
|
||||
|
||||
# Should have preferences object
|
||||
assert "preferences" in properties
|
||||
prefs_param = properties["preferences"]
|
||||
assert prefs_param["type"] == "object"
|
||||
|
||||
# Check nested structure
|
||||
prefs_props = prefs_param["properties"]
|
||||
assert "theme" in prefs_props
|
||||
assert "notifications" in prefs_props
|
||||
assert "privacy" in prefs_props
|
||||
|
||||
# Check deeply nested objects
|
||||
notifications = prefs_props["notifications"]
|
||||
assert notifications["type"] == "object"
|
||||
notif_props = notifications["properties"]
|
||||
assert "email" in notif_props
|
||||
assert "push" in notif_props
|
||||
assert "frequency" in notif_props
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepobject_tool_functionality(self, deepobject_spec):
|
||||
"""Test that tools with deepObject parameters maintain basic functionality."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=deepobject_spec,
|
||||
client=client,
|
||||
name="DeepObject Test Server",
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Should successfully create tools with deepObject parameters
|
||||
assert len(tools) == 2
|
||||
|
||||
tool_names = {tool.name for tool in tools}
|
||||
assert "get_surveys" in tool_names
|
||||
assert "update_preferences" in tool_names
|
||||
|
||||
# All tools should have valid schemas
|
||||
for tool in tools:
|
||||
assert tool.inputSchema is not None
|
||||
assert tool.inputSchema["type"] == "object"
|
||||
assert "properties" in tool.inputSchema
|
||||
|
||||
# Should have some properties
|
||||
assert len(tool.inputSchema["properties"]) > 0
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
"""End-to-end compatibility tests between legacy and new OpenAPI implementations."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.experimental.server.openapi import FastMCPOpenAPI
|
||||
from fastmcp.server.openapi import FastMCPOpenAPI as LegacyFastMCPOpenAPI
|
||||
|
||||
|
||||
class TestEndToEndCompatibility:
|
||||
"""Test that legacy and new implementations create identical tools."""
|
||||
|
||||
@pytest.fixture
|
||||
def simple_spec(self):
|
||||
"""Simple OpenAPI spec for testing."""
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"operationId": "get_user",
|
||||
"summary": "Get user by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
},
|
||||
{
|
||||
"name": "include_details",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "boolean"},
|
||||
},
|
||||
],
|
||||
"responses": {"200": {"description": "User found"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def collision_spec(self):
|
||||
"""OpenAPI spec with parameter collisions."""
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Collision API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/users/{id}": {
|
||||
"put": {
|
||||
"operationId": "update_user",
|
||||
"summary": "Update user",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["name"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {"200": {"description": "User updated"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async def test_tool_schema_compatibility(self, simple_spec):
|
||||
"""Test that tools have identical input schemas."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
# Create both servers
|
||||
legacy_server = LegacyFastMCPOpenAPI(
|
||||
openapi_spec=simple_spec,
|
||||
client=client,
|
||||
name="Legacy Server",
|
||||
)
|
||||
new_server = FastMCPOpenAPI(
|
||||
openapi_spec=simple_spec,
|
||||
client=client,
|
||||
name="New Server",
|
||||
)
|
||||
|
||||
# Get tools from both servers
|
||||
async with Client(legacy_server) as legacy_client:
|
||||
legacy_tools = await legacy_client.list_tools()
|
||||
|
||||
async with Client(new_server) as new_client:
|
||||
new_tools = await new_client.list_tools()
|
||||
|
||||
# Should have same number of tools
|
||||
assert len(legacy_tools) == len(new_tools)
|
||||
assert len(legacy_tools) == 1
|
||||
|
||||
# Get the single tool from each
|
||||
legacy_tool = legacy_tools[0]
|
||||
new_tool = new_tools[0]
|
||||
|
||||
# Names should be identical
|
||||
assert legacy_tool.name == new_tool.name
|
||||
assert legacy_tool.name == "get_user"
|
||||
|
||||
# Descriptions should be identical
|
||||
assert legacy_tool.description == new_tool.description
|
||||
|
||||
# Input schemas should be identical
|
||||
legacy_schema = legacy_tool.inputSchema
|
||||
new_schema = new_tool.inputSchema
|
||||
|
||||
# Required fields should match
|
||||
assert set(legacy_schema.get("required", [])) == set(
|
||||
new_schema.get("required", [])
|
||||
)
|
||||
|
||||
# Properties should match
|
||||
legacy_props = legacy_schema.get("properties", {})
|
||||
new_props = new_schema.get("properties", {})
|
||||
assert set(legacy_props.keys()) == set(new_props.keys())
|
||||
|
||||
# Check each property
|
||||
for prop_name in legacy_props:
|
||||
legacy_prop = legacy_props[prop_name]
|
||||
new_prop = new_props[prop_name]
|
||||
|
||||
# For required parameters, should have simple type
|
||||
if prop_name in legacy_schema.get("required", []):
|
||||
assert legacy_prop.get("type") == new_prop.get("type")
|
||||
assert "anyOf" not in legacy_prop
|
||||
assert "anyOf" not in new_prop
|
||||
else:
|
||||
# Both implementations now correctly preserve original schema without nullable behavior
|
||||
assert "anyOf" not in legacy_prop
|
||||
assert "anyOf" not in new_prop
|
||||
# Both should have the same type
|
||||
assert legacy_prop.get("type") == new_prop.get("type")
|
||||
|
||||
async def test_collision_handling_compatibility(self, collision_spec):
|
||||
"""Test that parameter collision handling is identical."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
# Create both servers
|
||||
legacy_server = LegacyFastMCPOpenAPI(
|
||||
openapi_spec=collision_spec,
|
||||
client=client,
|
||||
name="Legacy Server",
|
||||
)
|
||||
new_server = FastMCPOpenAPI(
|
||||
openapi_spec=collision_spec,
|
||||
client=client,
|
||||
name="New Server",
|
||||
)
|
||||
|
||||
# Get tools from both servers
|
||||
async with Client(legacy_server) as legacy_client:
|
||||
legacy_tools = await legacy_client.list_tools()
|
||||
|
||||
async with Client(new_server) as new_client:
|
||||
new_tools = await new_client.list_tools()
|
||||
|
||||
# Should have same number of tools
|
||||
assert len(legacy_tools) == len(new_tools)
|
||||
assert len(legacy_tools) == 1
|
||||
|
||||
# Get the single tool from each
|
||||
legacy_tool = legacy_tools[0]
|
||||
new_tool = new_tools[0]
|
||||
|
||||
# Input schemas should be identical
|
||||
legacy_schema = legacy_tool.inputSchema
|
||||
new_schema = new_tool.inputSchema
|
||||
|
||||
# Both should have collision-resolved parameters
|
||||
legacy_props = legacy_schema.get("properties", {})
|
||||
new_props = new_schema.get("properties", {})
|
||||
|
||||
# Should have: id__path (path param), id (body param), name (body param)
|
||||
expected_props = {"id__path", "id", "name"}
|
||||
assert set(legacy_props.keys()) == expected_props
|
||||
assert set(new_props.keys()) == expected_props
|
||||
|
||||
# Required should include path param and required body params
|
||||
legacy_required = set(legacy_schema.get("required", []))
|
||||
new_required = set(new_schema.get("required", []))
|
||||
assert legacy_required == new_required
|
||||
assert "id__path" in legacy_required
|
||||
assert "name" in legacy_required
|
||||
|
||||
# Path parameter should have integer type
|
||||
assert legacy_props["id__path"]["type"] == "integer"
|
||||
assert new_props["id__path"]["type"] == "integer"
|
||||
|
||||
# Body parameters should match
|
||||
assert legacy_props["id"]["type"] == "integer"
|
||||
assert new_props["id"]["type"] == "integer"
|
||||
assert legacy_props["name"]["type"] == "string"
|
||||
assert new_props["name"]["type"] == "string"
|
||||
|
||||
async def test_tool_execution_parameter_mapping(self, collision_spec):
|
||||
"""Test that tool execution with collisions works identically."""
|
||||
# This test verifies that both implementations can execute the same arguments
|
||||
# We can't easily test actual HTTP calls, but we can test argument validation
|
||||
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
# Create both servers
|
||||
legacy_server = LegacyFastMCPOpenAPI(
|
||||
openapi_spec=collision_spec,
|
||||
client=client,
|
||||
name="Legacy Server",
|
||||
)
|
||||
new_server = FastMCPOpenAPI(
|
||||
openapi_spec=collision_spec,
|
||||
client=client,
|
||||
name="New Server",
|
||||
)
|
||||
|
||||
# Test arguments that should work with collision resolution
|
||||
test_args = {
|
||||
"id__path": 123, # Path parameter (suffixed)
|
||||
"id": 456, # Body parameter (not suffixed)
|
||||
"name": "John Doe", # Body parameter
|
||||
}
|
||||
|
||||
async with Client(legacy_server) as legacy_client:
|
||||
async with Client(new_server) as new_client:
|
||||
# Both should accept the same arguments
|
||||
# We'll test this by attempting to call the tools
|
||||
# (they'll fail at HTTP level but should pass argument validation)
|
||||
|
||||
legacy_tools = await legacy_client.list_tools()
|
||||
new_tools = await new_client.list_tools()
|
||||
|
||||
legacy_tool_name = legacy_tools[0].name
|
||||
new_tool_name = new_tools[0].name
|
||||
|
||||
# Names should be identical
|
||||
assert legacy_tool_name == new_tool_name
|
||||
|
||||
# Both should fail at the HTTP request level (not argument validation)
|
||||
# This confirms the argument mapping works identically
|
||||
with pytest.raises(Exception) as legacy_exc:
|
||||
await legacy_client.call_tool(legacy_tool_name, test_args)
|
||||
|
||||
with pytest.raises(Exception) as new_exc:
|
||||
await new_client.call_tool(new_tool_name, test_args)
|
||||
|
||||
# Both should fail with similar error types (HTTP-related, not schema validation)
|
||||
# The exact error might differ but shouldn't be schema validation errors
|
||||
legacy_error = str(legacy_exc.value)
|
||||
new_error = str(new_exc.value)
|
||||
|
||||
# Neither should fail due to schema validation
|
||||
assert "schema" not in legacy_error.lower()
|
||||
assert "schema" not in new_error.lower()
|
||||
assert "validation" not in legacy_error.lower()
|
||||
assert "validation" not in new_error.lower()
|
||||
|
||||
async def test_optional_parameter_handling(self, simple_spec):
|
||||
"""Test that optional parameters are handled identically."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
# Create both servers
|
||||
legacy_server = LegacyFastMCPOpenAPI(
|
||||
openapi_spec=simple_spec,
|
||||
client=client,
|
||||
name="Legacy Server",
|
||||
)
|
||||
new_server = FastMCPOpenAPI(
|
||||
openapi_spec=simple_spec,
|
||||
client=client,
|
||||
name="New Server",
|
||||
)
|
||||
|
||||
# Test with optional parameter omitted (should be None/null)
|
||||
test_args_minimal = {"id": 123}
|
||||
|
||||
# Test with optional parameter included
|
||||
test_args_full = {"id": 123, "include_details": True}
|
||||
|
||||
async with Client(legacy_server) as legacy_client:
|
||||
async with Client(new_server) as new_client:
|
||||
legacy_tools = await legacy_client.list_tools()
|
||||
await new_client.list_tools()
|
||||
|
||||
tool_name = legacy_tools[0].name
|
||||
|
||||
# Both should handle minimal args the same way
|
||||
with pytest.raises(Exception) as legacy_exc_min:
|
||||
await legacy_client.call_tool(tool_name, test_args_minimal)
|
||||
|
||||
with pytest.raises(Exception) as new_exc_min:
|
||||
await new_client.call_tool(tool_name, test_args_minimal)
|
||||
|
||||
# Both should handle full args the same way
|
||||
with pytest.raises(Exception) as legacy_exc_full:
|
||||
await legacy_client.call_tool(tool_name, test_args_full)
|
||||
|
||||
with pytest.raises(Exception) as new_exc_full:
|
||||
await new_client.call_tool(tool_name, test_args_full)
|
||||
|
||||
# All should fail at HTTP level, not schema validation
|
||||
for exc in [
|
||||
legacy_exc_min,
|
||||
new_exc_min,
|
||||
legacy_exc_full,
|
||||
new_exc_full,
|
||||
]:
|
||||
error_msg = str(exc.value).lower()
|
||||
assert "schema" not in error_msg
|
||||
assert "validation" not in error_msg
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
"""Tests for OpenAPI feature support in openapi_new."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.experimental.server.openapi import FastMCPOpenAPI
|
||||
|
||||
|
||||
class TestParameterHandling:
|
||||
"""Test OpenAPI parameter handling features."""
|
||||
|
||||
@pytest.fixture
|
||||
def parameter_spec(self):
|
||||
"""OpenAPI spec with various parameter types."""
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Parameter Test API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com"}],
|
||||
"paths": {
|
||||
"/search": {
|
||||
"get": {
|
||||
"operationId": "search_items",
|
||||
"summary": "Search items",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
"description": "Search query",
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
"description": "Maximum number of results",
|
||||
},
|
||||
{
|
||||
"name": "tags",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"style": "form",
|
||||
"explode": True,
|
||||
"description": "Filter by tags",
|
||||
},
|
||||
{
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
"description": "API key for authentication",
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Search results",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"},
|
||||
},
|
||||
"total": {"type": "integer"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/users/{id}/posts/{post_id}": {
|
||||
"get": {
|
||||
"operationId": "get_user_post",
|
||||
"summary": "Get specific user post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
"description": "User ID",
|
||||
},
|
||||
{
|
||||
"name": "post_id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
"description": "Post ID",
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User post",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"title": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_parameters_in_tools(self, parameter_spec):
|
||||
"""Test that query parameters are properly included in tool parameters."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=parameter_spec, client=client, name="Parameter Test Server"
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Find the search tool
|
||||
search_tool = next(
|
||||
tool for tool in tools if tool.name == "search_items"
|
||||
)
|
||||
assert search_tool is not None
|
||||
|
||||
# Check that parameters are included in the tool's input schema
|
||||
params = search_tool.inputSchema
|
||||
assert params["type"] == "object"
|
||||
|
||||
properties = params["properties"]
|
||||
|
||||
# Check that key parameters are present
|
||||
# (Schema details may vary based on implementation)
|
||||
assert "query" in properties
|
||||
assert "limit" in properties
|
||||
assert "tags" in properties
|
||||
assert "X-API-Key" in properties
|
||||
|
||||
# Check that required parameters are marked as required
|
||||
required = params.get("required", [])
|
||||
assert "query" in required
|
||||
assert "X-API-Key" in required
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_parameters_in_tools(self, parameter_spec):
|
||||
"""Test that path parameters are properly included in tool parameters."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=parameter_spec, client=client, name="Parameter Test Server"
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Find the user post tool
|
||||
user_post_tool = next(
|
||||
tool for tool in tools if tool.name == "get_user_post"
|
||||
)
|
||||
assert user_post_tool is not None
|
||||
|
||||
# Check that path parameters are included
|
||||
params = user_post_tool.inputSchema
|
||||
properties = params["properties"]
|
||||
|
||||
# Check that path parameters are present
|
||||
assert "id" in properties
|
||||
assert "post_id" in properties
|
||||
|
||||
# Path parameters should be required
|
||||
required = params.get("required", [])
|
||||
assert "id" in required
|
||||
assert "post_id" in required
|
||||
|
||||
|
||||
class TestRequestBodyHandling:
|
||||
"""Test OpenAPI request body handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def request_body_spec(self):
|
||||
"""OpenAPI spec with request body."""
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Request Body Test API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com"}],
|
||||
"paths": {
|
||||
"/users": {
|
||||
"post": {
|
||||
"operationId": "create_user",
|
||||
"summary": "Create a user",
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "User's full name",
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "User's email address",
|
||||
},
|
||||
"age": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 150,
|
||||
"description": "User's age",
|
||||
},
|
||||
"preferences": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme": {"type": "string"},
|
||||
"notifications": {
|
||||
"type": "boolean"
|
||||
},
|
||||
},
|
||||
"description": "User preferences",
|
||||
},
|
||||
},
|
||||
"required": ["name", "email"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "User created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_body_properties_in_tool(self, request_body_spec):
|
||||
"""Test that request body properties are included in tool parameters."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=request_body_spec,
|
||||
client=client,
|
||||
name="Request Body Test Server",
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Find the create user tool
|
||||
create_tool = next(tool for tool in tools if tool.name == "create_user")
|
||||
assert create_tool is not None
|
||||
|
||||
# Check that request body properties are included
|
||||
params = create_tool.inputSchema
|
||||
properties = params["properties"]
|
||||
|
||||
# Check that request body properties are present
|
||||
assert "name" in properties
|
||||
assert "email" in properties
|
||||
assert "age" in properties
|
||||
assert "preferences" in properties
|
||||
|
||||
# Check required fields from request body
|
||||
required = params.get("required", [])
|
||||
assert "name" in required
|
||||
assert "email" in required
|
||||
|
||||
|
||||
class TestResponseSchemas:
|
||||
"""Test OpenAPI response schema handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def response_schema_spec(self):
|
||||
"""OpenAPI spec with detailed response schemas."""
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Response Schema Test API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com"}],
|
||||
"paths": {
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"operationId": "get_user",
|
||||
"summary": "Get user details",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User details retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string"},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bio": {"type": "string"},
|
||||
"avatar_url": {
|
||||
"type": "string"
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["id", "name", "email"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {"type": "string"},
|
||||
"code": {"type": "integer"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_has_output_schema(self, response_schema_spec):
|
||||
"""Test that tools have output schemas from response definitions."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=response_schema_spec,
|
||||
client=client,
|
||||
name="Response Schema Test Server",
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Find the get user tool
|
||||
get_user_tool = next(tool for tool in tools if tool.name == "get_user")
|
||||
assert get_user_tool is not None
|
||||
|
||||
# Check that the tool has an output schema
|
||||
# Note: output schema might be None if not extracted properly
|
||||
# Let's just check the tool exists and has basic properties
|
||||
assert get_user_tool.description is not None
|
||||
assert get_user_tool.name == "get_user"
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
"""Performance regression tests for OpenAPI parsing.
|
||||
|
||||
These tests ensure that large OpenAPI schemas (like GitHub's API) parse quickly
|
||||
and don't regress to the slow performance we had before optimization.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.tests import temporary_settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def use_new_openapi_parser():
|
||||
with temporary_settings(experimental__enable_new_openapi_parser=True):
|
||||
yield
|
||||
|
||||
|
||||
class TestOpenAPIPerformance:
|
||||
"""Performance tests for OpenAPI parsing with real-world large schemas."""
|
||||
|
||||
# 10 second maximum timeout for this test no matter what
|
||||
@pytest.mark.timeout(10)
|
||||
async def test_github_api_schema_performance(self):
|
||||
"""
|
||||
Test that GitHub's full API schema parses quickly.
|
||||
|
||||
This is a regression test to ensure our performance optimizations
|
||||
(eliminating deepcopy, single-pass optimization, smart union adjustment)
|
||||
continue to work. Without these optimizations, this test would take
|
||||
multiple minutes to parse.
|
||||
|
||||
On a local machine, this tests passes in ~2 seconds, but in GHA CI we see
|
||||
times as high as 6-7 seconds, so the test is asserted to pass in under
|
||||
10. Given that, this isn't intended to be a strict performance test, but
|
||||
rather a canary to ensure we don't regress significantly.
|
||||
"""
|
||||
|
||||
# Download the full GitHub API schema (typically ~10MB)
|
||||
response = httpx.get(
|
||||
"https://raw.githubusercontent.com/github/rest-api-description/refs/heads/main/descriptions-next/ghes-3.17/ghes-3.17.json",
|
||||
timeout=30.0, # Allow time for download
|
||||
)
|
||||
response.raise_for_status()
|
||||
schema = response.json()
|
||||
|
||||
# Time the parsing operation
|
||||
start_time = time.time()
|
||||
|
||||
# This should complete quickly with our optimizations
|
||||
mcp_server = FastMCP.from_openapi(schema, httpx.AsyncClient())
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
print(f"OpenAPI parsing took {elapsed_time:.2f}s")
|
||||
|
||||
# Verify the server was created successfully
|
||||
assert mcp_server is not None
|
||||
|
||||
# Performance regression test: should complete in under 10 seconds
|
||||
assert elapsed_time < 10.0, (
|
||||
f"OpenAPI parsing took {elapsed_time:.2f}s, exceeding 10s limit. "
|
||||
f"This suggests a performance regression."
|
||||
)
|
||||
|
||||
# Verify server and tools were created successfully
|
||||
tools = await mcp_server.get_tools()
|
||||
assert len(tools) > 500
|
||||
|
||||
def test_medium_schema_performance(self):
|
||||
"""
|
||||
Test parsing performance with a smaller synthetic schema.
|
||||
|
||||
This test doesn't require network access and provides a baseline
|
||||
for performance testing in CI environments.
|
||||
"""
|
||||
# Create a medium-sized synthetic schema
|
||||
schema = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"paths": {},
|
||||
}
|
||||
|
||||
# Generate multiple paths to create a reasonably sized schema
|
||||
for i in range(100):
|
||||
path = f"/test/{i}"
|
||||
schema["paths"][path] = {
|
||||
"get": {
|
||||
"operationId": f"test_{i}",
|
||||
"parameters": [
|
||||
{"name": "param1", "in": "query", "schema": {"type": "string"}}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"value": {"type": "string"},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated": {
|
||||
"type": "string"
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Time the parsing
|
||||
start_time = time.time()
|
||||
mcp_server = FastMCP.from_openapi(schema, httpx.AsyncClient())
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# Should be very fast for medium schemas (well under 1 second)
|
||||
assert elapsed_time < 1.0, (
|
||||
f"Medium schema parsing took {elapsed_time:.3f}s, expected <1s"
|
||||
)
|
||||
assert mcp_server is not None
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
"""Tests for parameter collision handling in openapi_new."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.experimental.server.openapi import FastMCPOpenAPI
|
||||
|
||||
|
||||
class TestParameterCollisions:
|
||||
"""Test parameter name collisions between different locations (path, query, body)."""
|
||||
|
||||
@pytest.fixture
|
||||
def collision_spec(self):
|
||||
"""OpenAPI spec with parameter name collisions."""
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Collision Test API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com"}],
|
||||
"paths": {
|
||||
"/users/{id}": {
|
||||
"put": {
|
||||
"operationId": "update_user",
|
||||
"summary": "Update user with collision between path and body",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
"description": "User ID in path",
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "User ID in body (different from path)",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "User name",
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "User email",
|
||||
},
|
||||
},
|
||||
"required": ["name", "email"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/search": {
|
||||
"get": {
|
||||
"operationId": "search_with_collision",
|
||||
"summary": "Search with query and header collision",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
"description": "Search query parameter",
|
||||
},
|
||||
{
|
||||
"name": "query",
|
||||
"in": "header",
|
||||
"required": False,
|
||||
"schema": {"type": "string"},
|
||||
"description": "Search query in header",
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Search results",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_body_collision_handling(self, collision_spec):
|
||||
"""Test that path and body parameters with same name are handled correctly."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=collision_spec, client=client, name="Collision Test Server"
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Find the update user tool
|
||||
update_tool = next(tool for tool in tools if tool.name == "update_user")
|
||||
assert update_tool is not None
|
||||
|
||||
# Check that both path and body 'id' parameters are included
|
||||
params = update_tool.inputSchema
|
||||
properties = params["properties"]
|
||||
|
||||
# Should have both path ID and body ID (with potential suffixing)
|
||||
# The implementation should handle this collision by suffixing one of them
|
||||
assert "id" in properties # One version of id
|
||||
|
||||
# Check for suffixed versions or verify both exist somehow
|
||||
# The exact handling depends on implementation, but both should be accessible
|
||||
param_names = list(properties.keys())
|
||||
id_params = [name for name in param_names if "id" in name]
|
||||
assert len(id_params) >= 1 # At least one id parameter
|
||||
|
||||
# Should also have other body parameters
|
||||
assert "name" in properties
|
||||
assert "email" in properties
|
||||
|
||||
# Required fields should include path parameter and required body fields
|
||||
required = params.get("required", [])
|
||||
assert "name" in required
|
||||
assert "email" in required
|
||||
# Path parameter should be required (may be suffixed)
|
||||
id_required = any("id" in req for req in required)
|
||||
assert id_required
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_header_collision_handling(self, collision_spec):
|
||||
"""Test that query and header parameters with same name are handled correctly."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=collision_spec, client=client, name="Collision Test Server"
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Find the search tool
|
||||
search_tool = next(
|
||||
tool for tool in tools if tool.name == "search_with_collision"
|
||||
)
|
||||
assert search_tool is not None
|
||||
|
||||
# Check that both query and header 'query' parameters are handled
|
||||
params = search_tool.inputSchema
|
||||
properties = params["properties"]
|
||||
|
||||
# Should handle the collision somehow (suffixing or other mechanism)
|
||||
param_names = list(properties.keys())
|
||||
query_params = [name for name in param_names if "query" in name]
|
||||
assert len(query_params) >= 1 # At least one query parameter
|
||||
|
||||
# Required should include the required query parameter
|
||||
required = params.get("required", [])
|
||||
query_required = any("query" in req for req in required)
|
||||
assert query_required
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collision_resolution_maintains_functionality(self, collision_spec):
|
||||
"""Test that collision resolution doesn't break basic tool functionality."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=collision_spec, client=client, name="Collision Test Server"
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Should successfully create tools despite collisions
|
||||
assert len(tools) == 2
|
||||
|
||||
tool_names = {tool.name for tool in tools}
|
||||
assert "update_user" in tool_names
|
||||
assert "search_with_collision" in tool_names
|
||||
|
||||
# Tools should have valid schemas
|
||||
for tool in tools:
|
||||
assert tool.inputSchema is not None
|
||||
assert tool.inputSchema["type"] == "object"
|
||||
assert "properties" in tool.inputSchema
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
"""Performance comparison between legacy and new OpenAPI implementations."""
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from fastmcp.experimental.server.openapi import FastMCPOpenAPI
|
||||
from fastmcp.server.openapi import FastMCPOpenAPI as LegacyFastMCPOpenAPI
|
||||
|
||||
|
||||
class TestPerformanceComparison:
|
||||
"""Compare performance between legacy and new implementations."""
|
||||
|
||||
@pytest.fixture
|
||||
def comprehensive_spec(self):
|
||||
"""Comprehensive OpenAPI spec for performance testing."""
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Performance Test API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/users": {
|
||||
"get": {
|
||||
"operationId": "list_users",
|
||||
"summary": "List users",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "default": 10},
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "default": 0},
|
||||
},
|
||||
],
|
||||
"responses": {"200": {"description": "Users listed"}},
|
||||
},
|
||||
"post": {
|
||||
"operationId": "create_user",
|
||||
"summary": "Create user",
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
"required": ["name", "email"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {"201": {"description": "User created"}},
|
||||
},
|
||||
},
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"operationId": "get_user",
|
||||
"summary": "Get user",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
],
|
||||
"responses": {"200": {"description": "User found"}},
|
||||
},
|
||||
"put": {
|
||||
"operationId": "update_user",
|
||||
"summary": "Update user",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {"200": {"description": "User updated"}},
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "delete_user",
|
||||
"summary": "Delete user",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
],
|
||||
"responses": {"204": {"description": "User deleted"}},
|
||||
},
|
||||
},
|
||||
"/search": {
|
||||
"get": {
|
||||
"operationId": "search_users",
|
||||
"summary": "Search users",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
{
|
||||
"name": "filters",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"style": "deepObject",
|
||||
"explode": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age_min": {"type": "integer"},
|
||||
"age_max": {"type": "integer"},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active", "inactive"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {"200": {"description": "Search results"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def test_server_initialization_performance(self, comprehensive_spec):
|
||||
"""Test that new implementation is significantly faster than legacy."""
|
||||
num_iterations = 5
|
||||
|
||||
# Measure legacy implementation
|
||||
legacy_times = []
|
||||
for _ in range(num_iterations):
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
start_time = time.time()
|
||||
server = LegacyFastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_spec,
|
||||
client=client,
|
||||
name="Legacy Performance Test",
|
||||
)
|
||||
# Ensure server is fully initialized
|
||||
assert server is not None
|
||||
end_time = time.time()
|
||||
legacy_times.append(end_time - start_time)
|
||||
|
||||
# Measure new implementation
|
||||
new_times = []
|
||||
for _ in range(num_iterations):
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
start_time = time.time()
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_spec,
|
||||
client=client,
|
||||
name="New Performance Test",
|
||||
)
|
||||
# Ensure server is fully initialized
|
||||
assert server is not None
|
||||
end_time = time.time()
|
||||
new_times.append(end_time - start_time)
|
||||
|
||||
# Calculate averages
|
||||
legacy_avg = sum(legacy_times) / len(legacy_times)
|
||||
new_avg = sum(new_times) / len(new_times)
|
||||
|
||||
print(f"Legacy implementation average: {legacy_avg:.4f}s")
|
||||
print(f"New implementation average: {new_avg:.4f}s")
|
||||
print(f"Speedup: {legacy_avg / new_avg:.2f}x")
|
||||
|
||||
# Both implementations should be very fast for moderate specs
|
||||
# The key achievement is eliminating the 100-200ms latency issue for serverless
|
||||
max_acceptable_time = 0.1 # 100ms
|
||||
|
||||
print(f"Legacy performance: {'✓' if legacy_avg < max_acceptable_time else '✗'}")
|
||||
print(f"New performance: {'✓' if new_avg < max_acceptable_time else '✗'}")
|
||||
|
||||
# New implementation should be under 100ms for reasonable specs (serverless requirement)
|
||||
assert new_avg < max_acceptable_time, (
|
||||
f"New implementation should initialize in under 100ms, got {new_avg:.4f}s"
|
||||
)
|
||||
|
||||
# Legacy might be slightly faster or slower on small specs, but both should be fast
|
||||
# The real improvement shows up with larger specs where code generation was the bottleneck
|
||||
assert legacy_avg < max_acceptable_time, (
|
||||
f"Legacy should also be fast on small specs, got {legacy_avg:.4f}s"
|
||||
)
|
||||
|
||||
# Performance should be comparable (within reasonable margin)
|
||||
performance_ratio = max(new_avg, legacy_avg) / min(new_avg, legacy_avg)
|
||||
assert performance_ratio < 3.0, (
|
||||
f"Performance should be comparable, ratio: {performance_ratio:.2f}x"
|
||||
)
|
||||
|
||||
def test_functionality_identical_after_optimization(self, comprehensive_spec):
|
||||
"""Verify that performance optimization doesn't break functionality."""
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
|
||||
# Create both servers
|
||||
legacy_server = LegacyFastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_spec,
|
||||
client=client,
|
||||
name="Legacy Server",
|
||||
)
|
||||
new_server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_spec,
|
||||
client=client,
|
||||
name="New Server",
|
||||
)
|
||||
|
||||
# Both should have the same number of tools
|
||||
legacy_tool_count = len(legacy_server._tool_manager._tools)
|
||||
new_tool_count = len(new_server._tool_manager._tools)
|
||||
|
||||
assert legacy_tool_count == new_tool_count
|
||||
assert legacy_tool_count == 6 # 6 operations in the spec
|
||||
|
||||
# Tool names should be identical
|
||||
legacy_tool_names = set(legacy_server._tool_manager._tools.keys())
|
||||
new_tool_names = set(new_server._tool_manager._tools.keys())
|
||||
|
||||
assert legacy_tool_names == new_tool_names
|
||||
|
||||
# Expected operations
|
||||
expected_operations = {
|
||||
"list_users",
|
||||
"create_user",
|
||||
"get_user",
|
||||
"update_user",
|
||||
"delete_user",
|
||||
"search_users",
|
||||
}
|
||||
assert legacy_tool_names == expected_operations
|
||||
|
||||
def test_memory_efficiency(self, comprehensive_spec):
|
||||
"""Test that new implementation doesn't significantly increase memory usage."""
|
||||
import gc
|
||||
|
||||
# This is a basic test - in practice you'd use more sophisticated memory profiling
|
||||
gc.collect() # Clean up before baseline
|
||||
baseline_refs = len(gc.get_objects())
|
||||
|
||||
servers = []
|
||||
for i in range(10):
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=comprehensive_spec,
|
||||
client=client,
|
||||
name=f"Memory Test Server {i}",
|
||||
)
|
||||
servers.append(server)
|
||||
|
||||
# Servers should all be functional
|
||||
assert len(servers) == 10
|
||||
assert all(len(s._tool_manager._tools) == 6 for s in servers)
|
||||
|
||||
# Memory usage shouldn't explode (this is a basic check)
|
||||
gc.collect() # Clean up
|
||||
current_refs = len(gc.get_objects())
|
||||
# Allow reasonable memory growth but not exponential
|
||||
growth_ratio = current_refs / max(baseline_refs, 1)
|
||||
assert growth_ratio < 3.0, (
|
||||
f"Memory usage grew by {growth_ratio}x, which seems excessive"
|
||||
)
|
||||
340
tests/experimental/openapi_parser/server/openapi/test_server.py
Normal file
340
tests/experimental/openapi_parser/server/openapi/test_server.py
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"""Unit tests for FastMCPOpenAPI server."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.experimental.server.openapi import FastMCPOpenAPI
|
||||
|
||||
|
||||
class TestFastMCPOpenAPIBasicFunctionality:
|
||||
"""Test basic FastMCPOpenAPI server functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def simple_openapi_spec(self):
|
||||
"""Simple OpenAPI spec for testing."""
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com"}],
|
||||
"paths": {
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"operationId": "get_user",
|
||||
"summary": "Get user by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/users": {
|
||||
"post": {
|
||||
"operationId": "create_user",
|
||||
"summary": "Create a new user",
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string"},
|
||||
},
|
||||
"required": ["name", "email"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "User created successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def test_server_initialization(self, simple_openapi_spec):
|
||||
"""Test server initialization with OpenAPI spec."""
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=simple_openapi_spec, client=client, name="Test Server"
|
||||
)
|
||||
|
||||
assert server.name == "Test Server"
|
||||
# Should have initialized RequestDirector successfully
|
||||
assert hasattr(server, "_director")
|
||||
assert hasattr(server, "_spec")
|
||||
|
||||
def test_server_initialization_with_custom_name(self, simple_openapi_spec):
|
||||
"""Test server initialization with custom name."""
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
|
||||
server = FastMCPOpenAPI(openapi_spec=simple_openapi_spec, client=client)
|
||||
|
||||
# Should use default name
|
||||
assert server.name == "OpenAPI FastMCP"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_creates_tools_from_spec(self, simple_openapi_spec):
|
||||
"""Test that server creates tools from OpenAPI spec."""
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=simple_openapi_spec, client=client, name="Test Server"
|
||||
)
|
||||
|
||||
# Test with in-memory client
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Should have created tools for both operations
|
||||
assert len(tools) == 2
|
||||
|
||||
tool_names = {tool.name for tool in tools}
|
||||
assert "get_user" in tool_names
|
||||
assert "create_user" in tool_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_tool_execution_fallback_to_http(self, simple_openapi_spec):
|
||||
"""Test tool execution falls back to HTTP when callables aren't available."""
|
||||
# Use a mock client that will be used for HTTP fallback
|
||||
mock_client = httpx.AsyncClient()
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=simple_openapi_spec, client=mock_client, name="Test Server"
|
||||
)
|
||||
|
||||
# With new architecture, tools are always created using RequestDirector
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
# Should still have tools even without callables
|
||||
assert len(tools) == 2
|
||||
|
||||
# Tools should be OpenAPITool instances using RequestDirector
|
||||
# We'll just verify they exist and are callable
|
||||
get_user_tool = next(tool for tool in tools if tool.name == "get_user")
|
||||
assert get_user_tool is not None
|
||||
assert get_user_tool.description is not None
|
||||
|
||||
def test_server_request_director_initialization(self, simple_openapi_spec):
|
||||
"""Test that server initializes RequestDirector successfully."""
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
|
||||
# This should not raise an exception
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=simple_openapi_spec, client=client, name="Test Server"
|
||||
)
|
||||
|
||||
# Server should be created successfully
|
||||
assert server is not None
|
||||
assert server.name == "Test Server"
|
||||
# RequestDirector and Spec should be initialized
|
||||
assert hasattr(server, "_director")
|
||||
assert hasattr(server, "_spec")
|
||||
|
||||
def test_server_with_timeout(self, simple_openapi_spec):
|
||||
"""Test server initialization with timeout setting."""
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=simple_openapi_spec,
|
||||
client=client,
|
||||
name="Test Server",
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
assert server._timeout == 30.0
|
||||
|
||||
def test_server_with_empty_spec(self):
|
||||
"""Test server with minimal OpenAPI spec."""
|
||||
minimal_spec = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Empty API", "version": "1.0.0"},
|
||||
"paths": {},
|
||||
}
|
||||
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=minimal_spec, client=client, name="Empty Server"
|
||||
)
|
||||
|
||||
assert server.name == "Empty Server"
|
||||
# Should handle empty paths gracefully
|
||||
assert hasattr(server, "_director")
|
||||
assert hasattr(server, "_spec")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clean_schema_output_no_unused_defs(self):
|
||||
"""Test that unused schema definitions are removed from tool schemas."""
|
||||
# Create a spec with unused HTTPValidationError-like definitions
|
||||
spec_with_unused_defs = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com"}],
|
||||
"paths": {
|
||||
"/users": {
|
||||
"post": {
|
||||
"operationId": "create_user",
|
||||
"summary": "Create a new user",
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "title": "Name"},
|
||||
"active": {
|
||||
"type": "boolean",
|
||||
"title": "Active",
|
||||
},
|
||||
},
|
||||
"required": ["name", "active"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User created successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"title": "Id",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name",
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean",
|
||||
"title": "Active",
|
||||
},
|
||||
},
|
||||
"required": ["id", "name", "active"],
|
||||
"title": "User",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
# This should be removed since it's not referenced
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
},
|
||||
"title": "Detail",
|
||||
"type": "array",
|
||||
}
|
||||
},
|
||||
"title": "HTTPValidationError",
|
||||
"type": "object",
|
||||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"loc": {
|
||||
"items": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer"}]
|
||||
},
|
||||
"title": "Location",
|
||||
"type": "array",
|
||||
},
|
||||
"msg": {"title": "Message", "type": "string"},
|
||||
"type": {"title": "Error Type", "type": "string"},
|
||||
},
|
||||
"required": ["loc", "msg", "type"],
|
||||
"title": "ValidationError",
|
||||
"type": "object",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=spec_with_unused_defs, client=client, name="Test Server"
|
||||
)
|
||||
|
||||
async with Client(server) as mcp_client:
|
||||
tools = await mcp_client.list_tools()
|
||||
|
||||
assert len(tools) == 1 # Only the POST operation
|
||||
tool = tools[0]
|
||||
|
||||
# Verify tool has clean schemas without unused $defs
|
||||
assert tool.name == "create_user"
|
||||
|
||||
# Input schema should not have $defs since no references are used
|
||||
expected_input_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "title": "Name"},
|
||||
"active": {"type": "boolean", "title": "Active"},
|
||||
},
|
||||
"required": ["name", "active"],
|
||||
}
|
||||
assert tool.inputSchema == expected_input_schema
|
||||
|
||||
# Output schema should not have $defs since no references are used
|
||||
expected_output_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer", "title": "Id"},
|
||||
"name": {"type": "string", "title": "Name"},
|
||||
"active": {"type": "boolean", "title": "Active"},
|
||||
},
|
||||
"required": ["id", "name", "active"],
|
||||
"title": "User",
|
||||
}
|
||||
assert tool.outputSchema == expected_output_schema
|
||||
Loading…
Add table
Add a link
Reference in a new issue