Clean up complexity from PR #1426 (#1435)

This commit is contained in:
Jeremiah Lowin 2025-08-10 21:55:17 -04:00 committed by GitHub
commit f8f05ca395
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 26 additions and 78 deletions

View file

@ -0,0 +1 @@
"""Tests for openapi_new server components."""

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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"
)

View 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