mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
323 lines
13 KiB
Python
323 lines
13 KiB
Python
"""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
|