Spaces:
Running
Running
| import base64 | |
| import json | |
| import re | |
| from enum import Enum | |
| import httpx | |
| import pytest | |
| from dirty_equals import IsStr | |
| from fastapi import FastAPI, HTTPException, Response | |
| from fastapi.responses import PlainTextResponse | |
| from httpx import ASGITransport, AsyncClient | |
| from mcp.types import BlobResourceContents, TextContent, TextResourceContents | |
| from pydantic import BaseModel, TypeAdapter | |
| from pydantic.networks import AnyUrl | |
| from fastmcp import FastMCP | |
| from fastmcp.client import Client | |
| from fastmcp.exceptions import ToolError | |
| from fastmcp.server.openapi import ( | |
| FastMCPOpenAPI, | |
| MCPType, | |
| OpenAPIResource, | |
| OpenAPIResourceTemplate, | |
| OpenAPITool, | |
| RouteMap, | |
| ) | |
| class User(BaseModel): | |
| id: int | |
| name: str | |
| active: bool | |
| class UserCreate(BaseModel): | |
| name: str | |
| active: bool | |
| def users_db() -> dict[int, User]: | |
| return { | |
| 1: User(id=1, name="Alice", active=True), | |
| 2: User(id=2, name="Bob", active=True), | |
| 3: User(id=3, name="Charlie", active=False), | |
| } | |
| def fastapi_app(users_db: dict[int, User]) -> FastAPI: | |
| app = FastAPI(title="FastAPI App") | |
| async def get_users() -> list[User]: | |
| """Get all users.""" | |
| return sorted(users_db.values(), key=lambda x: x.id) | |
| async def search_users( | |
| name: str | None = None, active: bool | None = None, min_id: int | None = None | |
| ) -> list[User]: | |
| """Search users with optional filters.""" | |
| results = list(users_db.values()) | |
| if name is not None: | |
| results = [u for u in results if name.lower() in u.name.lower()] | |
| if active is not None: | |
| results = [u for u in results if u.active == active] | |
| if min_id is not None: | |
| results = [u for u in results if u.id >= min_id] | |
| return sorted(results, key=lambda x: x.id) | |
| async def get_user(user_id: int) -> User | None: | |
| """Get a user by ID.""" | |
| return users_db.get(user_id) | |
| async def get_user_active_state(user_id: int, is_active: bool) -> User | None: | |
| """Get a user by ID and filter by active state.""" | |
| user = users_db.get(user_id) | |
| if user is not None and user.active == is_active: | |
| return user | |
| return None | |
| async def create_user(user: UserCreate) -> User: | |
| """Create a new user.""" | |
| user_id = max(users_db.keys()) + 1 | |
| new_user = User(id=user_id, **user.model_dump()) | |
| users_db[user_id] = new_user | |
| return new_user | |
| async def update_user_name(user_id: int, name: str) -> User: | |
| """Update a user's name.""" | |
| user = users_db.get(user_id) | |
| if user is None: | |
| raise HTTPException(status_code=404, detail="User not found") | |
| user.name = name | |
| return user | |
| async def ping() -> str: | |
| """Ping the server.""" | |
| return "pong" | |
| async def ping_bytes() -> Response: | |
| """Ping the server and get a bytes response.""" | |
| return Response(content=b"pong") | |
| return app | |
| def api_client(fastapi_app: FastAPI) -> AsyncClient: | |
| """Create a pre-configured httpx client for testing.""" | |
| return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test") | |
| async def fastmcp_openapi_server( | |
| fastapi_app: FastAPI, api_client: httpx.AsyncClient | |
| ) -> FastMCPOpenAPI: | |
| openapi_spec = fastapi_app.openapi() | |
| return FastMCPOpenAPI( | |
| openapi_spec=openapi_spec, | |
| client=api_client, | |
| name="Test App", | |
| ) | |
| async def test_create_openapi_server( | |
| fastapi_app: FastAPI, api_client: httpx.AsyncClient | |
| ): | |
| openapi_spec = fastapi_app.openapi() | |
| server = FastMCPOpenAPI( | |
| openapi_spec=openapi_spec, client=api_client, name="Test App" | |
| ) | |
| assert isinstance(server, FastMCP) | |
| assert server.name == "Test App" | |
| async def test_create_openapi_server_classmethod( | |
| fastapi_app: FastAPI, api_client: httpx.AsyncClient | |
| ): | |
| server = FastMCP.from_openapi(openapi_spec=fastapi_app.openapi(), client=api_client) | |
| assert isinstance(server, FastMCPOpenAPI) | |
| assert server.name == "OpenAPI FastMCP" | |
| async def test_create_fastapi_server_classmethod(fastapi_app: FastAPI): | |
| server = FastMCP.from_fastapi(fastapi_app) | |
| assert isinstance(server, FastMCPOpenAPI) | |
| assert server.name == "FastAPI App" | |
| async def test_create_openapi_server_with_timeout( | |
| fastapi_app: FastAPI, api_client: httpx.AsyncClient | |
| ): | |
| server = FastMCPOpenAPI( | |
| openapi_spec=fastapi_app.openapi(), | |
| client=api_client, | |
| name="Test App", | |
| timeout=1.0, | |
| ) | |
| assert server._timeout == 1.0 | |
| for tool in (await server.get_tools()).values(): | |
| assert isinstance(tool, OpenAPITool) | |
| assert tool._timeout == 1.0 | |
| for resource in (await server.get_resources()).values(): | |
| assert isinstance(resource, OpenAPIResource) | |
| assert resource._timeout == 1.0 | |
| for template in (await server.get_resource_templates()).values(): | |
| assert isinstance(template, OpenAPIResourceTemplate) | |
| assert template._timeout == 1.0 | |
| class TestTools: | |
| async def test_list_tools(self, fastmcp_openapi_server: FastMCPOpenAPI): | |
| """ | |
| By default, tools exclude GET methods | |
| """ | |
| async with Client(fastmcp_openapi_server) as client: | |
| tools = await client.list_tools() | |
| assert len(tools) == 2 | |
| assert tools[0].model_dump() == dict( | |
| name="create_user_users_post", | |
| annotations=None, | |
| description=IsStr(regex=r"^Create a new user\..*$", regex_flags=re.DOTALL), | |
| inputSchema={ | |
| "type": "object", | |
| "properties": { | |
| "name": {"type": "string", "title": "Name"}, | |
| "active": {"type": "boolean", "title": "Active"}, | |
| }, | |
| "required": ["name", "active"], | |
| }, | |
| ) | |
| assert tools[1].model_dump() == dict( | |
| name="update_user_name_users", | |
| annotations=None, | |
| description=IsStr( | |
| regex=r"^Update a user's name\..*$", regex_flags=re.DOTALL | |
| ), | |
| inputSchema={ | |
| "type": "object", | |
| "properties": { | |
| "user_id": {"type": "integer", "title": "User Id"}, | |
| "name": {"type": "string", "title": "Name"}, | |
| }, | |
| "required": ["user_id", "name"], | |
| }, | |
| ) | |
| async def test_call_create_user_tool( | |
| self, fastmcp_openapi_server: FastMCPOpenAPI, api_client | |
| ): | |
| """ | |
| The tool created by the OpenAPI server should be the same as the original | |
| """ | |
| async with Client(fastmcp_openapi_server) as client: | |
| tool_response = await client.call_tool( | |
| "create_user_users_post", {"name": "David", "active": False} | |
| ) | |
| # Convert TextContent to dict for comparison | |
| assert isinstance(tool_response, list) and len(tool_response) == 1 | |
| assert isinstance(tool_response[0], TextContent) | |
| response_data = json.loads(tool_response[0].text) | |
| expected_user = User(id=4, name="David", active=False).model_dump() | |
| assert response_data == expected_user | |
| # Check that the user was created via API | |
| response = await api_client.get("/users") | |
| assert len(response.json()) == 4 | |
| # Check that the user was created via MCP | |
| async with Client(fastmcp_openapi_server) as client: | |
| user_response = await client.read_resource("resource://get_user_users/4") | |
| assert isinstance(user_response[0], TextResourceContents) | |
| response_text = user_response[0].text | |
| user = json.loads(response_text) | |
| assert user == expected_user | |
| async def test_call_update_user_name_tool( | |
| self, fastmcp_openapi_server: FastMCPOpenAPI, api_client | |
| ): | |
| """ | |
| The tool created by the OpenAPI server should be the same as the original | |
| """ | |
| async with Client(fastmcp_openapi_server) as client: | |
| tool_response = await client.call_tool( | |
| "update_user_name_users", | |
| {"user_id": 1, "name": "XYZ"}, | |
| ) | |
| # Convert TextContent to dict for comparison | |
| assert isinstance(tool_response, list) and len(tool_response) == 1 | |
| assert isinstance(tool_response[0], TextContent) | |
| response_data = json.loads(tool_response[0].text) | |
| expected_data = dict(id=1, name="XYZ", active=True) | |
| assert response_data == expected_data | |
| # Check that the user was updated via API | |
| response = await api_client.get("/users") | |
| assert expected_data in response.json() | |
| # Check that the user was updated via MCP | |
| async with Client(fastmcp_openapi_server) as client: | |
| user_response = await client.read_resource("resource://get_user_users/1") | |
| assert isinstance(user_response[0], TextResourceContents) | |
| response_text = user_response[0].text | |
| user = json.loads(response_text) | |
| assert user == expected_data | |
| async def test_call_tool_return_list( | |
| self, | |
| fastapi_app: FastAPI, | |
| api_client: httpx.AsyncClient, | |
| users_db: dict[int, User], | |
| ): | |
| """ | |
| The tool created by the OpenAPI server should return a list of content. | |
| """ | |
| openapi_spec = fastapi_app.openapi() | |
| mcp_server = FastMCPOpenAPI( | |
| openapi_spec=openapi_spec, | |
| client=api_client, | |
| route_maps=[ | |
| RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL) | |
| ], | |
| ) | |
| async with Client(mcp_server) as client: | |
| tool_response = await client.call_tool("get_users_users_get", {}) | |
| assert isinstance(tool_response, list) | |
| assert isinstance(tool_response[0], TextContent) | |
| assert json.loads(tool_response[0].text) == [ | |
| user.model_dump() | |
| for user in sorted(users_db.values(), key=lambda x: x.id) | |
| ] | |
| class TestResources: | |
| async def test_list_resources(self, fastmcp_openapi_server: FastMCPOpenAPI): | |
| """ | |
| By default, resources exclude GET methods without parameters | |
| """ | |
| async with Client(fastmcp_openapi_server) as client: | |
| resources = await client.list_resources() | |
| assert len(resources) == 4 | |
| assert resources[0].uri == AnyUrl("resource://get_users_users_get") | |
| assert resources[0].name == "get_users_users_get" | |
| async def test_get_resource( | |
| self, | |
| fastmcp_openapi_server: FastMCPOpenAPI, | |
| api_client, | |
| users_db: dict[int, User], | |
| ): | |
| """ | |
| The resource created by the OpenAPI server should be the same as the original | |
| """ | |
| json_users = TypeAdapter(list[User]).dump_python( | |
| sorted(users_db.values(), key=lambda x: x.id) | |
| ) | |
| async with Client(fastmcp_openapi_server) as client: | |
| resource_response = await client.read_resource( | |
| "resource://get_users_users_get" | |
| ) | |
| assert isinstance(resource_response[0], TextResourceContents) | |
| response_text = resource_response[0].text | |
| resource = json.loads(response_text) | |
| assert resource == json_users | |
| response = await api_client.get("/users") | |
| assert response.json() == json_users | |
| async def test_get_bytes_resource( | |
| self, | |
| fastmcp_openapi_server: FastMCPOpenAPI, | |
| api_client, | |
| ): | |
| """Test reading a resource that returns bytes.""" | |
| async with Client(fastmcp_openapi_server) as client: | |
| resource_response = await client.read_resource( | |
| "resource://ping_bytes_ping_bytes_get" | |
| ) | |
| assert isinstance(resource_response[0], BlobResourceContents) | |
| assert base64.b64decode(resource_response[0].blob) == b"pong" | |
| async def test_get_str_resource( | |
| self, | |
| fastmcp_openapi_server: FastMCPOpenAPI, | |
| api_client, | |
| ): | |
| """Test reading a resource that returns a string.""" | |
| async with Client(fastmcp_openapi_server) as client: | |
| resource_response = await client.read_resource("resource://ping_ping_get") | |
| assert isinstance(resource_response[0], TextResourceContents) | |
| assert resource_response[0].text == "pong" | |
| class TestResourceTemplates: | |
| async def test_list_resource_templates( | |
| self, fastmcp_openapi_server: FastMCPOpenAPI | |
| ): | |
| """ | |
| By default, resource templates exclude GET methods without parameters | |
| """ | |
| async with Client(fastmcp_openapi_server) as client: | |
| resource_templates = await client.list_resource_templates() | |
| assert len(resource_templates) == 2 | |
| assert resource_templates[0].name == "get_user_users" | |
| assert ( | |
| resource_templates[0].uriTemplate == r"resource://get_user_users/{user_id}" | |
| ) | |
| assert resource_templates[1].name == "get_user_active_state_users" | |
| assert ( | |
| resource_templates[1].uriTemplate | |
| == r"resource://get_user_active_state_users/{is_active}/{user_id}" | |
| ) | |
| async def test_get_resource_template( | |
| self, | |
| fastmcp_openapi_server: FastMCPOpenAPI, | |
| api_client, | |
| users_db: dict[int, User], | |
| ): | |
| """ | |
| The resource template created by the OpenAPI server should be the same as the original | |
| """ | |
| user_id = 2 | |
| async with Client(fastmcp_openapi_server) as client: | |
| resource_response = await client.read_resource( | |
| f"resource://get_user_users/{user_id}" | |
| ) | |
| assert isinstance(resource_response[0], TextResourceContents) | |
| response_text = resource_response[0].text | |
| resource = json.loads(response_text) | |
| assert resource == users_db[user_id].model_dump() | |
| response = await api_client.get(f"/users/{user_id}") | |
| assert resource == response.json() | |
| async def test_get_resource_template_multi_param( | |
| self, | |
| fastmcp_openapi_server: FastMCPOpenAPI, | |
| api_client, | |
| users_db: dict[int, User], | |
| ): | |
| """ | |
| The resource template created by the OpenAPI server should be the same as the original | |
| """ | |
| user_id = 2 | |
| is_active = True | |
| async with Client(fastmcp_openapi_server) as client: | |
| resource_response = await client.read_resource( | |
| f"resource://get_user_active_state_users/{is_active}/{user_id}" | |
| ) | |
| assert isinstance(resource_response[0], TextResourceContents) | |
| response_text = resource_response[0].text | |
| resource = json.loads(response_text) | |
| assert resource == users_db[user_id].model_dump() | |
| response = await api_client.get(f"/users/{user_id}/{is_active}") | |
| assert resource == response.json() | |
| class TestPrompts: | |
| async def test_list_prompts(self, fastmcp_openapi_server: FastMCPOpenAPI): | |
| """ | |
| By default, there are no prompts. | |
| """ | |
| async with Client(fastmcp_openapi_server) as client: | |
| prompts = await client.list_prompts() | |
| assert len(prompts) == 0 | |
| class TestTagTransfer: | |
| """Tests for transferring tags from OpenAPI routes to MCP objects.""" | |
| async def test_tags_transferred_to_tools( | |
| self, fastmcp_openapi_server: FastMCPOpenAPI | |
| ): | |
| """Test that tags from OpenAPI routes are correctly transferred to Tools.""" | |
| # Get internal tools directly (not the public API which returns MCP.Content) | |
| tools = fastmcp_openapi_server._tool_manager.list_tools() | |
| # Find the create_user and update_user_name tools | |
| create_user_tool = next( | |
| (t for t in tools if t.name == "create_user_users_post"), None | |
| ) | |
| update_user_tool = next( | |
| (t for t in tools if t.name == "update_user_name_users"), | |
| None, | |
| ) | |
| assert create_user_tool is not None | |
| assert update_user_tool is not None | |
| # Check that tags from OpenAPI routes were transferred to the Tool objects | |
| assert "users" in create_user_tool.tags | |
| assert "create" in create_user_tool.tags | |
| assert len(create_user_tool.tags) == 2 | |
| assert "users" in update_user_tool.tags | |
| assert "update" in update_user_tool.tags | |
| assert len(update_user_tool.tags) == 2 | |
| async def test_tags_transferred_to_resources( | |
| self, fastmcp_openapi_server: FastMCPOpenAPI | |
| ): | |
| """Test that tags from OpenAPI routes are correctly transferred to Resources.""" | |
| # Get internal resources directly | |
| resources = list( | |
| fastmcp_openapi_server._resource_manager.get_resources().values() | |
| ) | |
| # Find the get_users resource | |
| get_users_resource = next( | |
| (r for r in resources if r.name == "get_users_users_get"), None | |
| ) | |
| assert get_users_resource is not None | |
| # Check that tags from OpenAPI routes were transferred to the Resource object | |
| assert "users" in get_users_resource.tags | |
| assert "list" in get_users_resource.tags | |
| assert len(get_users_resource.tags) == 2 | |
| async def test_tags_transferred_to_resource_templates( | |
| self, fastmcp_openapi_server: FastMCPOpenAPI | |
| ): | |
| """Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates.""" | |
| # Get internal resource templates directly | |
| templates = list( | |
| fastmcp_openapi_server._resource_manager.get_templates().values() | |
| ) | |
| # Find the get_user template | |
| get_user_template = next( | |
| (t for t in templates if t.name == "get_user_users"), None | |
| ) | |
| assert get_user_template is not None | |
| # Check that tags from OpenAPI routes were transferred to the ResourceTemplate object | |
| assert "users" in get_user_template.tags | |
| assert "detail" in get_user_template.tags | |
| assert len(get_user_template.tags) == 2 | |
| async def test_tags_preserved_in_resources_created_from_templates( | |
| self, fastmcp_openapi_server: FastMCPOpenAPI | |
| ): | |
| """Test that tags are preserved when creating resources from templates.""" | |
| # Get internal resource templates directly | |
| templates = list( | |
| fastmcp_openapi_server._resource_manager.get_templates().values() | |
| ) | |
| # Find the get_user template | |
| get_user_template = next( | |
| (t for t in templates if t.name == "get_user_users"), None | |
| ) | |
| assert get_user_template is not None | |
| # Manually create a resource from template | |
| params = {"user_id": 1} | |
| resource = await get_user_template.create_resource( | |
| "resource://get_user_users/1", params | |
| ) | |
| # Verify tags are preserved from template to resource | |
| assert "users" in resource.tags | |
| assert "detail" in resource.tags | |
| assert len(resource.tags) == 2 | |
| class TestOpenAPI30Compatibility: | |
| """Tests for compatibility with OpenAPI 3.0 specifications.""" | |
| def openapi_30_spec(self) -> dict: | |
| """Fixture that returns a simple OpenAPI 3.0 specification.""" | |
| return { | |
| "openapi": "3.0.0", | |
| "info": {"title": "Product API (3.0)", "version": "1.0.0"}, | |
| "paths": { | |
| "/products": { | |
| "get": { | |
| "operationId": "listProducts", | |
| "summary": "List all products", | |
| "responses": {"200": {"description": "A list of products"}}, | |
| }, | |
| "post": { | |
| "operationId": "createProduct", | |
| "summary": "Create a new product", | |
| "requestBody": { | |
| "required": True, | |
| "content": { | |
| "application/json": { | |
| "schema": { | |
| "type": "object", | |
| "properties": { | |
| "name": {"type": "string"}, | |
| "price": {"type": "number"}, | |
| }, | |
| "required": ["name", "price"], | |
| } | |
| } | |
| }, | |
| }, | |
| "responses": {"201": {"description": "Product created"}}, | |
| }, | |
| }, | |
| "/products/{product_id}": { | |
| "get": { | |
| "operationId": "getProduct", | |
| "summary": "Get product by ID", | |
| "parameters": [ | |
| { | |
| "name": "product_id", | |
| "in": "path", | |
| "required": True, | |
| "schema": {"type": "string"}, | |
| } | |
| ], | |
| "responses": {"200": {"description": "A product"}}, | |
| } | |
| }, | |
| }, | |
| } | |
| async def mock_30_client(self) -> httpx.AsyncClient: | |
| """Mock client that returns predefined responses for the 3.0 API.""" | |
| async def _responder(request): | |
| if request.url.path == "/products" and request.method == "GET": | |
| return httpx.Response( | |
| 200, | |
| json=[ | |
| {"id": "p1", "name": "Product 1", "price": 19.99}, | |
| {"id": "p2", "name": "Product 2", "price": 29.99}, | |
| ], | |
| ) | |
| elif request.url.path == "/products" and request.method == "POST": | |
| import json | |
| data = json.loads(request.content) | |
| return httpx.Response( | |
| 201, json={"id": "p3", "name": data["name"], "price": data["price"]} | |
| ) | |
| elif request.url.path.startswith("/products/") and request.method == "GET": | |
| product_id = request.url.path.split("/")[-1] | |
| products = { | |
| "p1": {"id": "p1", "name": "Product 1", "price": 19.99}, | |
| "p2": {"id": "p2", "name": "Product 2", "price": 29.99}, | |
| } | |
| if product_id in products: | |
| return httpx.Response(200, json=products[product_id]) | |
| return httpx.Response(404, json={"error": "Product not found"}) | |
| return httpx.Response(404) | |
| transport = httpx.MockTransport(_responder) | |
| return httpx.AsyncClient(transport=transport, base_url="http://test") | |
| async def openapi_30_server( | |
| self, openapi_30_spec, mock_30_client | |
| ) -> FastMCPOpenAPI: | |
| """Create a FastMCPOpenAPI server from the OpenAPI 3.0 spec.""" | |
| return FastMCPOpenAPI( | |
| openapi_spec=openapi_30_spec, client=mock_30_client, name="Product API 3.0" | |
| ) | |
| async def test_server_creation(self, openapi_30_server): | |
| """Test that a server can be created from an OpenAPI 3.0 spec.""" | |
| assert isinstance(openapi_30_server, FastMCP) | |
| assert openapi_30_server.name == "Product API 3.0" | |
| async def test_resource_discovery(self, openapi_30_server): | |
| """Test that resources are correctly discovered from an OpenAPI 3.0 spec.""" | |
| async with Client(openapi_30_server) as client: | |
| resources = await client.list_resources() | |
| assert len(resources) == 1 | |
| assert resources[0].uri == AnyUrl("resource://listProducts") | |
| async def test_resource_template_discovery(self, openapi_30_server): | |
| """Test that resource templates are correctly discovered from an OpenAPI 3.0 spec.""" | |
| async with Client(openapi_30_server) as client: | |
| templates = await client.list_resource_templates() | |
| assert len(templates) == 1 | |
| assert templates[0].name == "getProduct" | |
| assert templates[0].uriTemplate == r"resource://getProduct/{product_id}" | |
| async def test_tool_discovery(self, openapi_30_server): | |
| """Test that tools are correctly discovered from an OpenAPI 3.0 spec.""" | |
| async with Client(openapi_30_server) as client: | |
| tools = await client.list_tools() | |
| assert len(tools) == 1 | |
| assert tools[0].name == "createProduct" | |
| assert "name" in tools[0].inputSchema["properties"] | |
| assert "price" in tools[0].inputSchema["properties"] | |
| async def test_resource_access(self, openapi_30_server): | |
| """Test reading a resource from an OpenAPI 3.0 server.""" | |
| async with Client(openapi_30_server) as client: | |
| resource_response = await client.read_resource("resource://listProducts") | |
| assert isinstance(resource_response[0], TextResourceContents) | |
| response_text = resource_response[0].text | |
| content = json.loads(response_text) | |
| assert len(content) == 2 | |
| assert content[0]["name"] == "Product 1" | |
| assert content[1]["name"] == "Product 2" | |
| async def test_resource_template_access(self, openapi_30_server): | |
| """Test reading a resource from template from an OpenAPI 3.0 server.""" | |
| async with Client(openapi_30_server) as client: | |
| resource_response = await client.read_resource("resource://getProduct/p1") | |
| assert isinstance(resource_response[0], TextResourceContents) | |
| response_text = resource_response[0].text | |
| content = json.loads(response_text) | |
| assert content["id"] == "p1" | |
| assert content["name"] == "Product 1" | |
| assert content["price"] == 19.99 | |
| async def test_tool_execution(self, openapi_30_server): | |
| """Test executing a tool from an OpenAPI 3.0 server.""" | |
| async with Client(openapi_30_server) as client: | |
| result = await client.call_tool( | |
| "createProduct", {"name": "New Product", "price": 39.99} | |
| ) | |
| # Result should be a text content | |
| assert len(result) == 1 | |
| assert isinstance(result[0], TextContent) | |
| product = json.loads(result[0].text) | |
| assert product["id"] == "p3" | |
| assert product["name"] == "New Product" | |
| assert product["price"] == 39.99 | |
| class TestOpenAPI31Compatibility: | |
| """Tests for compatibility with OpenAPI 3.1 specifications.""" | |
| def openapi_31_spec(self) -> dict: | |
| """Fixture that returns a simple OpenAPI 3.1 specification.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "Order API (3.1)", "version": "1.0.0"}, | |
| "paths": { | |
| "/orders": { | |
| "get": { | |
| "operationId": "listOrders", | |
| "summary": "List all orders", | |
| "responses": {"200": {"description": "A list of orders"}}, | |
| }, | |
| "post": { | |
| "operationId": "createOrder", | |
| "summary": "Place a new order", | |
| "requestBody": { | |
| "required": True, | |
| "content": { | |
| "application/json": { | |
| "schema": { | |
| "type": "object", | |
| "properties": { | |
| "customer": {"type": "string"}, | |
| "items": { | |
| "type": "array", | |
| "items": {"type": "string"}, | |
| }, | |
| }, | |
| "required": ["customer", "items"], | |
| } | |
| } | |
| }, | |
| }, | |
| "responses": {"201": {"description": "Order created"}}, | |
| }, | |
| }, | |
| "/orders/{order_id}": { | |
| "get": { | |
| "operationId": "getOrder", | |
| "summary": "Get order by ID", | |
| "parameters": [ | |
| { | |
| "name": "order_id", | |
| "in": "path", | |
| "required": True, | |
| "schema": {"type": "string"}, | |
| } | |
| ], | |
| "responses": {"200": {"description": "An order"}}, | |
| } | |
| }, | |
| }, | |
| } | |
| async def mock_31_client(self) -> httpx.AsyncClient: | |
| """Mock client that returns predefined responses for the 3.1 API.""" | |
| async def _responder(request): | |
| if request.url.path == "/orders" and request.method == "GET": | |
| return httpx.Response( | |
| 200, | |
| json=[ | |
| {"id": "o1", "customer": "Alice", "items": ["item1", "item2"]}, | |
| {"id": "o2", "customer": "Bob", "items": ["item3"]}, | |
| ], | |
| ) | |
| elif request.url.path == "/orders" and request.method == "POST": | |
| import json | |
| data = json.loads(request.content) | |
| return httpx.Response( | |
| 201, | |
| json={ | |
| "id": "o3", | |
| "customer": data["customer"], | |
| "items": data["items"], | |
| }, | |
| ) | |
| elif request.url.path.startswith("/orders/") and request.method == "GET": | |
| order_id = request.url.path.split("/")[-1] | |
| orders = { | |
| "o1": { | |
| "id": "o1", | |
| "customer": "Alice", | |
| "items": ["item1", "item2"], | |
| }, | |
| "o2": {"id": "o2", "customer": "Bob", "items": ["item3"]}, | |
| } | |
| if order_id in orders: | |
| return httpx.Response(200, json=orders[order_id]) | |
| return httpx.Response(404, json={"error": "Order not found"}) | |
| return httpx.Response(404) | |
| transport = httpx.MockTransport(_responder) | |
| return httpx.AsyncClient(transport=transport, base_url="http://test") | |
| async def openapi_31_server( | |
| self, openapi_31_spec, mock_31_client | |
| ) -> FastMCPOpenAPI: | |
| """Create a FastMCPOpenAPI server from the OpenAPI 3.1 spec.""" | |
| return FastMCPOpenAPI( | |
| openapi_spec=openapi_31_spec, client=mock_31_client, name="Order API 3.1" | |
| ) | |
| async def test_server_creation(self, openapi_31_server): | |
| """Test that a server can be created from an OpenAPI 3.1 spec.""" | |
| assert isinstance(openapi_31_server, FastMCP) | |
| assert openapi_31_server.name == "Order API 3.1" | |
| async def test_resource_discovery(self, openapi_31_server): | |
| """Test that resources are correctly discovered from an OpenAPI 3.1 spec.""" | |
| async with Client(openapi_31_server) as client: | |
| resources = await client.list_resources() | |
| assert len(resources) == 1 | |
| assert resources[0].uri == AnyUrl("resource://listOrders") | |
| async def test_resource_template_discovery(self, openapi_31_server): | |
| """Test that resource templates are correctly discovered from an OpenAPI 3.1 spec.""" | |
| async with Client(openapi_31_server) as client: | |
| templates = await client.list_resource_templates() | |
| assert len(templates) == 1 | |
| assert templates[0].name == "getOrder" | |
| assert templates[0].uriTemplate == r"resource://getOrder/{order_id}" | |
| async def test_tool_discovery(self, openapi_31_server): | |
| """Test that tools are correctly discovered from an OpenAPI 3.1 spec.""" | |
| async with Client(openapi_31_server) as client: | |
| tools = await client.list_tools() | |
| assert len(tools) == 1 | |
| assert tools[0].name == "createOrder" | |
| assert "customer" in tools[0].inputSchema["properties"] | |
| assert "items" in tools[0].inputSchema["properties"] | |
| async def test_resource_access(self, openapi_31_server): | |
| """Test reading a resource from an OpenAPI 3.1 server.""" | |
| async with Client(openapi_31_server) as client: | |
| resource_response = await client.read_resource("resource://listOrders") | |
| assert isinstance(resource_response[0], TextResourceContents) | |
| response_text = resource_response[0].text | |
| content = json.loads(response_text) | |
| assert len(content) == 2 | |
| assert content[0]["customer"] == "Alice" | |
| assert content[1]["customer"] == "Bob" | |
| async def test_resource_template_access(self, openapi_31_server): | |
| """Test reading a resource from template from an OpenAPI 3.1 server.""" | |
| async with Client(openapi_31_server) as client: | |
| resource_response = await client.read_resource("resource://getOrder/o1") | |
| assert isinstance(resource_response[0], TextResourceContents) | |
| response_text = resource_response[0].text | |
| content = json.loads(response_text) | |
| assert content["id"] == "o1" | |
| assert content["customer"] == "Alice" | |
| assert content["items"] == ["item1", "item2"] | |
| async def test_tool_execution(self, openapi_31_server): | |
| """Test executing a tool from an OpenAPI 3.1 server.""" | |
| async with Client(openapi_31_server) as client: | |
| result = await client.call_tool( | |
| "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]} | |
| ) | |
| # Result should be a text content | |
| assert len(result) == 1 | |
| assert isinstance(result[0], TextContent) | |
| order = json.loads(result[0].text) | |
| assert order["id"] == "o3" | |
| assert order["customer"] == "Charlie" | |
| assert order["items"] == ["item4", "item5"] | |
| class TestMountFastMCP: | |
| """Tests for mounting FastMCP servers.""" | |
| async def test_mount_fastmcp(self, fastmcp_openapi_server: FastMCPOpenAPI): | |
| """Test mounting an OpenAPI server.""" | |
| mcp = FastMCP("MainApp") | |
| await mcp.import_server("fastapi", fastmcp_openapi_server) | |
| # Check that resources are available with prefixed URIs | |
| async with Client(mcp) as client: | |
| resources = await client.list_resources() | |
| assert len(resources) == 4 # Updated to account for new search endpoint | |
| # We're checking the key used by mcp to store the resource | |
| # The prefixed URI is used as the key, but the resource's original uri is preserved | |
| prefixed_uri = "resource://fastapi/get_users_users_get" | |
| resource = mcp._resource_manager.get_resources().get(prefixed_uri) | |
| assert resource is not None | |
| async def test_empty_query_parameters_not_sent( | |
| fastapi_app: FastAPI, api_client: httpx.AsyncClient | |
| ): | |
| """Test that empty and None query parameters are not sent in the request.""" | |
| # Create a TransportAdapter to track requests | |
| class RequestCapture(httpx.AsyncBaseTransport): | |
| def __init__(self, wrapped): | |
| self.wrapped = wrapped | |
| self.requests = [] | |
| async def handle_async_request(self, request): | |
| self.requests.append(request) | |
| return await self.wrapped.handle_async_request(request) | |
| # Use our transport adapter to wrap the original one | |
| capture = RequestCapture(api_client._transport) | |
| api_client._transport = capture | |
| # Create the OpenAPI server with new route map to make search endpoint a tool | |
| openapi_spec = fastapi_app.openapi() | |
| mcp_server = FastMCPOpenAPI( | |
| openapi_spec=openapi_spec, | |
| client=api_client, | |
| route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)], | |
| ) | |
| # Call the search tool with mixed parameter values | |
| async with Client(mcp_server) as client: | |
| await client.call_tool( | |
| "search_users_search_get", | |
| { | |
| "name": "", # Empty string should be excluded | |
| "active": None, # None should be excluded | |
| "min_id": 2, # Has value, should be included | |
| }, | |
| ) | |
| # Verify that the request URL only has min_id parameter | |
| assert len(capture.requests) > 0 | |
| request = capture.requests[-1] # Get the last request | |
| # URL should only contain min_id=2, not name= or active= | |
| url = str(request.url) | |
| assert "min_id=2" in url, f"URL should contain min_id=2, got: {url}" | |
| assert "name=" not in url, f"URL should not contain name=, got: {url}" | |
| assert "active=" not in url, f"URL should not contain active=, got: {url}" | |
| # More direct check - parse the URL to examine query params | |
| from urllib.parse import parse_qs, urlparse | |
| parsed_url = urlparse(url) | |
| query_params = parse_qs(parsed_url.query) | |
| assert "min_id" in query_params | |
| assert "name" not in query_params | |
| assert "active" not in query_params | |
| async def test_none_path_parameters_rejected( | |
| fastapi_app: FastAPI, api_client: httpx.AsyncClient | |
| ): | |
| """Test that None values for path parameters are properly rejected.""" | |
| # Create the OpenAPI server | |
| openapi_spec = fastapi_app.openapi() | |
| mcp_server = FastMCPOpenAPI( | |
| openapi_spec=openapi_spec, | |
| client=api_client, | |
| ) | |
| # Create a client and try to call a tool with a None path parameter | |
| async with Client(mcp_server) as client: | |
| # get_user has a required path parameter user_id | |
| with pytest.raises(ToolError, match="Missing required path parameters"): | |
| await client.call_tool( | |
| "update_user_name_users", | |
| { | |
| "user_id": None, # This should cause an error | |
| "name": "New Name", | |
| }, | |
| ) | |
| class TestDescriptionPropagation: | |
| """Tests for OpenAPI description propagation to FastMCP components. | |
| Each test focuses on a single, specific behavior to make it immediately clear | |
| what's broken when a test fails. | |
| """ | |
| def simple_openapi_spec(self) -> dict: | |
| """Create a minimal OpenAPI spec with obvious test descriptions.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "Test API", "version": "1.0.0"}, | |
| "paths": { | |
| "/items": { | |
| "get": { | |
| "operationId": "listItems", | |
| "summary": "List items summary", | |
| "description": "LIST_DESCRIPTION\n\nFUNCTION_LIST_DESCRIPTION", | |
| "responses": { | |
| "200": { | |
| "description": "LIST_RESPONSE_DESCRIPTION", | |
| "content": { | |
| "application/json": { | |
| "schema": { | |
| "type": "array", | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "id": { | |
| "type": "string", | |
| "description": "ITEM_RESPONSE_ID_DESCRIPTION", | |
| }, | |
| "name": { | |
| "type": "string", | |
| "description": "ITEM_RESPONSE_NAME_DESCRIPTION", | |
| }, | |
| "price": { | |
| "type": "number", | |
| "description": "ITEM_RESPONSE_PRICE_DESCRIPTION", | |
| }, | |
| }, | |
| }, | |
| }, | |
| } | |
| }, | |
| } | |
| }, | |
| } | |
| }, | |
| "/items/{item_id}": { | |
| "get": { | |
| "operationId": "getItem", | |
| "summary": "Get item summary", | |
| "description": "GET_DESCRIPTION\n\nFUNCTION_GET_DESCRIPTION", | |
| "parameters": [ | |
| { | |
| "name": "item_id", | |
| "in": "path", | |
| "required": True, | |
| "description": "PATH_PARAM_DESCRIPTION", | |
| "schema": {"type": "string"}, | |
| }, | |
| { | |
| "name": "fields", | |
| "in": "query", | |
| "required": False, | |
| "description": "QUERY_PARAM_DESCRIPTION", | |
| "schema": {"type": "string"}, | |
| }, | |
| ], | |
| "responses": { | |
| "200": { | |
| "description": "GET_RESPONSE_DESCRIPTION", | |
| "content": { | |
| "application/json": { | |
| "schema": { | |
| "type": "object", | |
| "properties": { | |
| "id": { | |
| "type": "string", | |
| "description": "ITEM_RESPONSE_ID_DESCRIPTION", | |
| }, | |
| "name": { | |
| "type": "string", | |
| "description": "ITEM_RESPONSE_NAME_DESCRIPTION", | |
| }, | |
| "price": { | |
| "type": "number", | |
| "description": "ITEM_RESPONSE_PRICE_DESCRIPTION", | |
| }, | |
| }, | |
| }, | |
| } | |
| }, | |
| } | |
| }, | |
| } | |
| }, | |
| "/items/create": { | |
| "post": { | |
| "operationId": "createItem", | |
| "summary": "Create item summary", | |
| "description": "CREATE_DESCRIPTION\n\nFUNCTION_CREATE_DESCRIPTION", | |
| "requestBody": { | |
| "required": True, | |
| "description": "BODY_DESCRIPTION", | |
| "content": { | |
| "application/json": { | |
| "schema": { | |
| "type": "object", | |
| "properties": { | |
| "name": { | |
| "type": "string", | |
| "description": "PROP_DESCRIPTION", | |
| } | |
| }, | |
| "required": ["name"], | |
| } | |
| } | |
| }, | |
| }, | |
| "responses": { | |
| "201": { | |
| "description": "CREATE_RESPONSE_DESCRIPTION", | |
| "content": { | |
| "application/json": { | |
| "schema": { | |
| "type": "object", | |
| "properties": { | |
| "id": { | |
| "type": "string", | |
| "description": "ITEM_RESPONSE_ID_DESCRIPTION", | |
| }, | |
| "name": { | |
| "type": "string", | |
| "description": "ITEM_RESPONSE_NAME_DESCRIPTION", | |
| }, | |
| }, | |
| }, | |
| } | |
| }, | |
| } | |
| }, | |
| } | |
| }, | |
| }, | |
| } | |
| async def mock_client(self) -> httpx.AsyncClient: | |
| """Create a mock client that returns simple responses.""" | |
| async def _responder(request): | |
| if request.url.path == "/items" and request.method == "GET": | |
| return httpx.Response(200, json=[{"id": "1", "name": "Item 1"}]) | |
| elif request.url.path.startswith("/items/") and request.method == "GET": | |
| item_id = request.url.path.split("/")[-1] | |
| return httpx.Response( | |
| 200, json={"id": item_id, "name": f"Item {item_id}"} | |
| ) | |
| elif request.url.path == "/items/create" and request.method == "POST": | |
| import json | |
| data = json.loads(request.content) | |
| return httpx.Response(201, json={"id": "new", "name": data.get("name")}) | |
| return httpx.Response(404) | |
| transport = httpx.MockTransport(_responder) | |
| return httpx.AsyncClient(transport=transport, base_url="http://test") | |
| async def test_server(self, simple_openapi_spec, mock_client): | |
| """Create a FastMCPOpenAPI server with the simple test spec.""" | |
| return FastMCPOpenAPI( | |
| openapi_spec=simple_openapi_spec, | |
| client=mock_client, | |
| name="Test API", | |
| ) | |
| # --- RESOURCE TESTS --- | |
| async def test_resource_includes_route_description(self, test_server): | |
| """Test that a Resource includes the route description.""" | |
| resources = list(test_server._resource_manager.get_resources().values()) | |
| list_resource = next((r for r in resources if r.name == "listItems"), None) | |
| assert list_resource is not None, "listItems resource wasn't created" | |
| assert "LIST_DESCRIPTION" in (list_resource.description or ""), ( | |
| "Route description missing from Resource" | |
| ) | |
| async def test_resource_includes_response_description(self, test_server): | |
| """Test that a Resource includes the response description.""" | |
| resources = list(test_server._resource_manager.get_resources().values()) | |
| list_resource = next((r for r in resources if r.name == "listItems"), None) | |
| assert list_resource is not None, "listItems resource wasn't created" | |
| assert "LIST_RESPONSE_DESCRIPTION" in (list_resource.description or ""), ( | |
| "Response description missing from Resource" | |
| ) | |
| async def test_resource_includes_response_model_fields(self, test_server): | |
| """Test that a Resource description includes response model field descriptions.""" | |
| resources = list(test_server._resource_manager.get_resources().values()) | |
| list_resource = next((r for r in resources if r.name == "listItems"), None) | |
| assert list_resource is not None, "listItems resource wasn't created" | |
| description = list_resource.description or "" | |
| assert "ITEM_RESPONSE_ID_DESCRIPTION" in description, ( | |
| "Response model field descriptions missing from Resource description" | |
| ) | |
| assert "ITEM_RESPONSE_NAME_DESCRIPTION" in description, ( | |
| "Response model field descriptions missing from Resource description" | |
| ) | |
| assert "ITEM_RESPONSE_PRICE_DESCRIPTION" in description, ( | |
| "Response model field descriptions missing from Resource description" | |
| ) | |
| # --- RESOURCE TEMPLATE TESTS --- | |
| async def test_template_includes_route_description(self, test_server): | |
| """Test that a ResourceTemplate includes the route description.""" | |
| templates = list(test_server._resource_manager.get_templates().values()) | |
| get_template = next((t for t in templates if t.name == "getItem"), None) | |
| assert get_template is not None, "getItem template wasn't created" | |
| assert "GET_DESCRIPTION" in (get_template.description or ""), ( | |
| "Route description missing from ResourceTemplate" | |
| ) | |
| async def test_template_includes_function_docstring(self, test_server): | |
| """Test that a ResourceTemplate includes the function docstring.""" | |
| templates = list(test_server._resource_manager.get_templates().values()) | |
| get_template = next((t for t in templates if t.name == "getItem"), None) | |
| assert get_template is not None, "getItem template wasn't created" | |
| assert "FUNCTION_GET_DESCRIPTION" in (get_template.description or ""), ( | |
| "Function docstring missing from ResourceTemplate" | |
| ) | |
| async def test_template_includes_path_parameter_description(self, test_server): | |
| """Test that a ResourceTemplate includes path parameter descriptions.""" | |
| templates = list(test_server._resource_manager.get_templates().values()) | |
| get_template = next((t for t in templates if t.name == "getItem"), None) | |
| assert get_template is not None, "getItem template wasn't created" | |
| assert "PATH_PARAM_DESCRIPTION" in (get_template.description or ""), ( | |
| "Path parameter description missing from ResourceTemplate description" | |
| ) | |
| async def test_template_includes_query_parameter_description(self, test_server): | |
| """Test that a ResourceTemplate includes query parameter descriptions.""" | |
| templates = list(test_server._resource_manager.get_templates().values()) | |
| get_template = next((t for t in templates if t.name == "getItem"), None) | |
| assert get_template is not None, "getItem template wasn't created" | |
| assert "QUERY_PARAM_DESCRIPTION" in (get_template.description or ""), ( | |
| "Query parameter description missing from ResourceTemplate description" | |
| ) | |
| async def test_template_parameter_schema_includes_description(self, test_server): | |
| """Test that a ResourceTemplate's parameter schema includes parameter descriptions.""" | |
| templates = list(test_server._resource_manager.get_templates().values()) | |
| get_template = next((t for t in templates if t.name == "getItem"), None) | |
| assert get_template is not None, "getItem template wasn't created" | |
| assert "properties" in get_template.parameters, ( | |
| "Schema properties missing from ResourceTemplate" | |
| ) | |
| assert "item_id" in get_template.parameters["properties"], ( | |
| "item_id missing from ResourceTemplate schema" | |
| ) | |
| assert "description" in get_template.parameters["properties"]["item_id"], ( | |
| "Description missing from item_id parameter schema" | |
| ) | |
| assert ( | |
| "PATH_PARAM_DESCRIPTION" | |
| in get_template.parameters["properties"]["item_id"]["description"] | |
| ), "Path parameter description incorrect in schema" | |
| # --- TOOL TESTS --- | |
| async def test_tool_includes_route_description(self, test_server): | |
| """Test that a Tool includes the route description.""" | |
| tools = test_server._tool_manager.list_tools() | |
| create_tool = next((t for t in tools if t.name == "createItem"), None) | |
| assert create_tool is not None, "createItem tool wasn't created" | |
| assert "CREATE_DESCRIPTION" in (create_tool.description or ""), ( | |
| "Route description missing from Tool" | |
| ) | |
| async def test_tool_includes_function_docstring(self, test_server): | |
| """Test that a Tool includes the function docstring.""" | |
| tools = test_server._tool_manager.list_tools() | |
| create_tool = next((t for t in tools if t.name == "createItem"), None) | |
| assert create_tool is not None, "createItem tool wasn't created" | |
| description = create_tool.description or "" | |
| assert "FUNCTION_CREATE_DESCRIPTION" in description, ( | |
| "Function docstring missing from Tool" | |
| ) | |
| async def test_tool_parameter_schema_includes_property_description( | |
| self, test_server | |
| ): | |
| """Test that a Tool's parameter schema includes property descriptions from request model.""" | |
| tools = test_server._tool_manager.list_tools() | |
| create_tool = next((t for t in tools if t.name == "createItem"), None) | |
| assert create_tool is not None, "createItem tool wasn't created" | |
| assert "properties" in create_tool.parameters, ( | |
| "Schema properties missing from Tool" | |
| ) | |
| assert "name" in create_tool.parameters["properties"], ( | |
| "name parameter missing from Tool schema" | |
| ) | |
| assert "description" in create_tool.parameters["properties"]["name"], ( | |
| "Description missing from name parameter schema" | |
| ) | |
| assert ( | |
| "PROP_DESCRIPTION" | |
| in create_tool.parameters["properties"]["name"]["description"] | |
| ), "Property description incorrect in schema" | |
| # --- CLIENT API TESTS --- | |
| async def test_client_api_resource_description(self, test_server): | |
| """Test that Resource descriptions are accessible via the client API.""" | |
| async with Client(test_server) as client: | |
| resources = await client.list_resources() | |
| list_resource = next((r for r in resources if r.name == "listItems"), None) | |
| assert list_resource is not None, ( | |
| "listItems resource not accessible via client API" | |
| ) | |
| resource_description = list_resource.description or "" | |
| assert "LIST_DESCRIPTION" in resource_description, ( | |
| "Route description missing in Resource from client API" | |
| ) | |
| async def test_client_api_template_description(self, test_server): | |
| """Test that ResourceTemplate descriptions are accessible via the client API.""" | |
| async with Client(test_server) as client: | |
| templates = await client.list_resource_templates() | |
| get_template = next((t for t in templates if t.name == "getItem"), None) | |
| assert get_template is not None, ( | |
| "getItem template not accessible via client API" | |
| ) | |
| template_description = get_template.description or "" | |
| assert "GET_DESCRIPTION" in template_description, ( | |
| "Route description missing in ResourceTemplate from client API" | |
| ) | |
| async def test_client_api_tool_description(self, test_server): | |
| """Test that Tool descriptions are accessible via the client API.""" | |
| async with Client(test_server) as client: | |
| tools = await client.list_tools() | |
| create_tool = next((t for t in tools if t.name == "createItem"), None) | |
| assert create_tool is not None, ( | |
| "createItem tool not accessible via client API" | |
| ) | |
| tool_description = create_tool.description or "" | |
| assert "FUNCTION_CREATE_DESCRIPTION" in tool_description, ( | |
| "Function docstring missing in Tool from client API" | |
| ) | |
| async def test_client_api_tool_parameter_schema(self, test_server): | |
| """Test that Tool parameter schemas are accessible via the client API.""" | |
| async with Client(test_server) as client: | |
| tools = await client.list_tools() | |
| create_tool = next((t for t in tools if t.name == "createItem"), None) | |
| assert create_tool is not None, ( | |
| "createItem tool not accessible via client API" | |
| ) | |
| assert "properties" in create_tool.inputSchema, ( | |
| "Schema properties missing from Tool inputSchema in client API" | |
| ) | |
| assert "name" in create_tool.inputSchema["properties"], ( | |
| "name parameter missing from Tool schema in client API" | |
| ) | |
| assert "description" in create_tool.inputSchema["properties"]["name"], ( | |
| "Description missing from name parameter in client API" | |
| ) | |
| assert ( | |
| "PROP_DESCRIPTION" | |
| in create_tool.inputSchema["properties"]["name"]["description"] | |
| ), "Property description incorrect in schema from client API" | |
| class TestFastAPIDescriptionPropagation: | |
| """Tests for FastAPI docstring and annotation propagation to FastMCP components. | |
| Each test focuses on a single, specific behavior to make it immediately clear | |
| what's broken when a test fails. | |
| """ | |
| def fastapi_app_with_descriptions(self) -> FastAPI: | |
| """Create a simple FastAPI app with docstrings and annotations.""" | |
| from typing import Annotated | |
| from pydantic import BaseModel, Field | |
| app = FastAPI(title="Test FastAPI App") | |
| class Item(BaseModel): | |
| name: str = Field(..., description="ITEM_NAME_DESCRIPTION") | |
| price: float = Field(..., description="ITEM_PRICE_DESCRIPTION") | |
| class ItemResponse(BaseModel): | |
| id: str = Field(..., description="ITEM_RESPONSE_ID_DESCRIPTION") | |
| name: str = Field(..., description="ITEM_RESPONSE_NAME_DESCRIPTION") | |
| price: float = Field(..., description="ITEM_RESPONSE_PRICE_DESCRIPTION") | |
| async def list_items() -> list[ItemResponse]: | |
| """FUNCTION_LIST_DESCRIPTION | |
| Returns a list of items. | |
| """ | |
| return [ | |
| ItemResponse(id="1", name="Item 1", price=10.0), | |
| ItemResponse(id="2", name="Item 2", price=20.0), | |
| ] | |
| async def get_item( | |
| item_id: Annotated[str, Field(description="PATH_PARAM_DESCRIPTION")], | |
| fields: Annotated[ | |
| str | None, Field(description="QUERY_PARAM_DESCRIPTION") | |
| ] = None, | |
| ) -> ItemResponse: | |
| """FUNCTION_GET_DESCRIPTION | |
| Gets a specific item by ID. | |
| Args: | |
| item_id: The ID of the item to retrieve | |
| fields: Optional fields to include | |
| """ | |
| return ItemResponse( | |
| id=item_id, name=f"Item {item_id}", price=float(item_id) * 10.0 | |
| ) | |
| async def create_item(item: Item) -> ItemResponse: | |
| """FUNCTION_CREATE_DESCRIPTION | |
| Creates a new item. | |
| Body: | |
| Item object with name and price | |
| """ | |
| return ItemResponse(id="new", name=item.name, price=item.price) | |
| return app | |
| async def fastapi_server(self, fastapi_app_with_descriptions): | |
| """Create a FastMCP server from the FastAPI app with custom route mappings.""" | |
| # First create from FastAPI app to get the OpenAPI spec | |
| openapi_spec = fastapi_app_with_descriptions.openapi() | |
| # Debug: check the operationIds in the OpenAPI spec | |
| print("\nDEBUG - OpenAPI Paths:") | |
| for path, methods in openapi_spec["paths"].items(): | |
| for method, details in methods.items(): | |
| if method != "parameters": # Skip non-HTTP method keys | |
| operation_id = details.get("operationId", "no_operation_id") | |
| print( | |
| f" Path: {path}, Method: {method}, OperationId: {operation_id}" | |
| ) | |
| # Create custom route mappings | |
| route_maps = [ | |
| # Map GET /items to Resource | |
| RouteMap(methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE), | |
| # Map GET /items/{item_id} to ResourceTemplate | |
| RouteMap( | |
| methods=["GET"], | |
| pattern=r"^/items/\{.*\}$", | |
| mcp_type=MCPType.RESOURCE_TEMPLATE, | |
| ), | |
| # Map POST /items to Tool | |
| RouteMap(methods=["POST"], pattern=r"^/items$", mcp_type=MCPType.TOOL), | |
| ] | |
| # Create FastMCP server with the OpenAPI spec and custom route mappings | |
| server = FastMCPOpenAPI( | |
| openapi_spec=openapi_spec, | |
| client=AsyncClient( | |
| transport=ASGITransport(app=fastapi_app_with_descriptions), | |
| base_url="http://test", | |
| ), | |
| name="Test FastAPI App", | |
| route_maps=route_maps, | |
| ) | |
| # Debug: print all components created | |
| print("\nDEBUG - Resources created:") | |
| for name, resource in server._resource_manager.get_resources().items(): | |
| print(f" Resource: {name}, Name attribute: {resource.name}") | |
| print("\nDEBUG - Templates created:") | |
| for name, template in server._resource_manager.get_templates().items(): | |
| print(f" Template: {name}, Name attribute: {template.name}") | |
| print("\nDEBUG - Tools created:") | |
| for tool in server._tool_manager.list_tools(): | |
| print(f" Tool: {tool.name}") | |
| return server | |
| async def test_resource_includes_function_docstring(self, fastapi_server): | |
| """Test that a Resource includes the function docstring.""" | |
| resources = list(fastapi_server._resource_manager.get_resources().values()) | |
| # Now checking for the get_items operation ID rather than list_items | |
| list_resource = next((r for r in resources if "items_get" in r.name), None) | |
| assert list_resource is not None, "GET /items resource wasn't created" | |
| description = list_resource.description or "" | |
| assert "FUNCTION_LIST_DESCRIPTION" in description, ( | |
| "Function docstring missing from Resource" | |
| ) | |
| async def test_resource_includes_response_model_fields(self, fastapi_server): | |
| """Test that a Resource description includes basic response information. | |
| Note: FastAPI doesn't reliably include Pydantic field descriptions in the OpenAPI schema, | |
| so we can only check for basic response information being present. | |
| """ | |
| resources = list(fastapi_server._resource_manager.get_resources().values()) | |
| list_resource = next((r for r in resources if "items_get" in r.name), None) | |
| assert list_resource is not None, "GET /items resource wasn't created" | |
| description = list_resource.description or "" | |
| # Check that at least the response information is included | |
| assert "Successful Response" in description, ( | |
| "Response information missing from Resource description" | |
| ) | |
| # We've already verified in TestDescriptionPropagation that when descriptions | |
| # are present in the OpenAPI schema, they are properly included in the component description | |
| async def test_template_includes_function_docstring(self, fastapi_server): | |
| """Test that a ResourceTemplate includes the function docstring.""" | |
| templates = list(fastapi_server._resource_manager.get_templates().values()) | |
| get_template = next((t for t in templates if "get_item_items" in t.name), None) | |
| assert get_template is not None, "GET /items/{item_id} template wasn't created" | |
| description = get_template.description or "" | |
| assert "FUNCTION_GET_DESCRIPTION" in description, ( | |
| "Function docstring missing from ResourceTemplate" | |
| ) | |
| async def test_template_includes_path_parameter_description(self, fastapi_server): | |
| """Test that a ResourceTemplate includes path parameter descriptions. | |
| Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)] | |
| are not properly propagated to the OpenAPI schema. The parameters appear but without the description. | |
| """ | |
| templates = list(fastapi_server._resource_manager.get_templates().values()) | |
| get_template = next((t for t in templates if "get_item_items" in t.name), None) | |
| assert get_template is not None, "GET /items/{item_id} template wasn't created" | |
| description = get_template.description or "" | |
| # Just test that parameters are included at all | |
| assert "Path Parameters" in description, ( | |
| "Path parameters section missing from ResourceTemplate description" | |
| ) | |
| assert "item_id" in description, ( | |
| "item_id parameter missing from ResourceTemplate description" | |
| ) | |
| async def test_template_includes_query_parameter_description(self, fastapi_server): | |
| """Test that a ResourceTemplate includes query parameter descriptions. | |
| Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)] | |
| are not properly propagated to the OpenAPI schema. The parameters appear but without the description. | |
| """ | |
| templates = list(fastapi_server._resource_manager.get_templates().values()) | |
| get_template = next((t for t in templates if "get_item_items" in t.name), None) | |
| assert get_template is not None, "GET /items/{item_id} template wasn't created" | |
| description = get_template.description or "" | |
| # Just test that parameters are included at all | |
| assert "Query Parameters" in description, ( | |
| "Query parameters section missing from ResourceTemplate description" | |
| ) | |
| assert "fields" in description, ( | |
| "fields parameter missing from ResourceTemplate description" | |
| ) | |
| async def test_template_parameter_schema_includes_description(self, fastapi_server): | |
| """Test that a ResourceTemplate's parameter schema includes parameter descriptions.""" | |
| templates = list(fastapi_server._resource_manager.get_templates().values()) | |
| get_template = next((t for t in templates if "get_item_items" in t.name), None) | |
| assert get_template is not None, "GET /items/{item_id} template wasn't created" | |
| assert "properties" in get_template.parameters, ( | |
| "Schema properties missing from ResourceTemplate" | |
| ) | |
| assert "item_id" in get_template.parameters["properties"], ( | |
| "item_id missing from ResourceTemplate schema" | |
| ) | |
| assert "description" in get_template.parameters["properties"]["item_id"], ( | |
| "Description missing from item_id parameter schema" | |
| ) | |
| assert ( | |
| "PATH_PARAM_DESCRIPTION" | |
| in get_template.parameters["properties"]["item_id"]["description"] | |
| ), "Path parameter description incorrect in schema" | |
| async def test_tool_includes_function_docstring(self, fastapi_server): | |
| """Test that a Tool includes the function docstring.""" | |
| tools = fastapi_server._tool_manager.list_tools() | |
| create_tool = next( | |
| (t for t in tools if "create_item_items_post" == t.name), None | |
| ) | |
| assert create_tool is not None, "POST /items tool wasn't created" | |
| description = create_tool.description or "" | |
| assert "FUNCTION_CREATE_DESCRIPTION" in description, ( | |
| "Function docstring missing from Tool" | |
| ) | |
| async def test_tool_parameter_schema_includes_property_description( | |
| self, fastapi_server | |
| ): | |
| """Test that a Tool's parameter schema includes property descriptions from request model. | |
| Note: Currently, model field descriptions defined in Pydantic models using Field(description=...) | |
| may not be consistently propagated into the FastAPI OpenAPI schema and thus not into the tool's | |
| parameter schema. | |
| """ | |
| tools = fastapi_server._tool_manager.list_tools() | |
| create_tool = next( | |
| (t for t in tools if "create_item_items_post" == t.name), None | |
| ) | |
| assert create_tool is not None, "POST /items tool wasn't created" | |
| assert "properties" in create_tool.parameters, ( | |
| "Schema properties missing from Tool" | |
| ) | |
| assert "name" in create_tool.parameters["properties"], ( | |
| "name parameter missing from Tool schema" | |
| ) | |
| # We don't test for the description field content as it may not be consistently propagated | |
| async def test_client_api_resource_description(self, fastapi_server): | |
| """Test that Resource descriptions are accessible via the client API.""" | |
| async with Client(fastapi_server) as client: | |
| resources = await client.list_resources() | |
| list_resource = next((r for r in resources if "items_get" in r.name), None) | |
| assert list_resource is not None, ( | |
| "GET /items resource not accessible via client API" | |
| ) | |
| resource_description = list_resource.description or "" | |
| assert "FUNCTION_LIST_DESCRIPTION" in resource_description, ( | |
| "Function docstring missing in Resource from client API" | |
| ) | |
| async def test_client_api_template_description(self, fastapi_server): | |
| """Test that ResourceTemplate descriptions are accessible via the client API.""" | |
| async with Client(fastapi_server) as client: | |
| templates = await client.list_resource_templates() | |
| get_template = next( | |
| (t for t in templates if "get_item_items" in t.name), None | |
| ) | |
| assert get_template is not None, ( | |
| "GET /items/{item_id} template not accessible via client API" | |
| ) | |
| template_description = get_template.description or "" | |
| assert "FUNCTION_GET_DESCRIPTION" in template_description, ( | |
| "Function docstring missing in ResourceTemplate from client API" | |
| ) | |
| async def test_client_api_tool_description(self, fastapi_server): | |
| """Test that Tool descriptions are accessible via the client API.""" | |
| async with Client(fastapi_server) as client: | |
| tools = await client.list_tools() | |
| create_tool = next( | |
| (t for t in tools if "create_item_items_post" == t.name), None | |
| ) | |
| assert create_tool is not None, ( | |
| "POST /items tool not accessible via client API" | |
| ) | |
| tool_description = create_tool.description or "" | |
| assert "FUNCTION_CREATE_DESCRIPTION" in tool_description, ( | |
| "Function docstring missing in Tool from client API" | |
| ) | |
| async def test_client_api_tool_parameter_schema(self, fastapi_server): | |
| """Test that Tool parameter schemas are accessible via the client API.""" | |
| async with Client(fastapi_server) as client: | |
| tools = await client.list_tools() | |
| create_tool = next( | |
| (t for t in tools if "create_item_items_post" == t.name), None | |
| ) | |
| assert create_tool is not None, ( | |
| "POST /items tool not accessible via client API" | |
| ) | |
| assert "properties" in create_tool.inputSchema, ( | |
| "Schema properties missing from Tool inputSchema in client API" | |
| ) | |
| assert "name" in create_tool.inputSchema["properties"], ( | |
| "name parameter missing from Tool schema in client API" | |
| ) | |
| # We don't test for the description field content as it may not be consistently propagated | |
| class TestReprMethods: | |
| """Tests for the custom __repr__ methods of OpenAPI objects.""" | |
| async def test_openapi_tool_repr(self, fastmcp_openapi_server: FastMCPOpenAPI): | |
| """Test that OpenAPITool's __repr__ method works without recursion errors.""" | |
| tools = fastmcp_openapi_server._tool_manager.list_tools() | |
| tool = next(iter(tools)) | |
| # Verify repr doesn't cause recursion and contains expected elements | |
| tool_repr = repr(tool) | |
| assert "OpenAPITool" in tool_repr | |
| assert f"name={tool.name!r}" in tool_repr | |
| assert "method=" in tool_repr | |
| assert "path=" in tool_repr | |
| async def test_openapi_resource_repr(self, fastmcp_openapi_server: FastMCPOpenAPI): | |
| """Test that OpenAPIResource's __repr__ method works without recursion errors.""" | |
| resources = list( | |
| fastmcp_openapi_server._resource_manager.get_resources().values() | |
| ) | |
| resource = next(iter(resources)) | |
| # Verify repr doesn't cause recursion and contains expected elements | |
| resource_repr = repr(resource) | |
| assert "OpenAPIResource" in resource_repr | |
| assert f"name={resource.name!r}" in resource_repr | |
| assert "uri=" in resource_repr | |
| assert "path=" in resource_repr | |
| async def test_openapi_resource_template_repr( | |
| self, fastmcp_openapi_server: FastMCPOpenAPI | |
| ): | |
| """Test that OpenAPIResourceTemplate's __repr__ method works without recursion errors.""" | |
| templates = list( | |
| fastmcp_openapi_server._resource_manager.get_templates().values() | |
| ) | |
| template = next(iter(templates)) | |
| # Verify repr doesn't cause recursion and contains expected elements | |
| template_repr = repr(template) | |
| assert "OpenAPIResourceTemplate" in template_repr | |
| assert f"name={template.name!r}" in template_repr | |
| assert "uri_template=" in template_repr | |
| assert "path=" in template_repr | |
| class TestEnumHandling: | |
| """Tests for handling enum parameters in OpenAPI schemas.""" | |
| async def test_enum_parameter_schema(self): | |
| """Test that enum parameters are properly handled in tool parameter schemas.""" | |
| # Define an enum just like in example.py | |
| class QueryEnum(str, Enum): | |
| foo = "foo" | |
| bar = "bar" | |
| baz = "baz" | |
| # Create a minimal FastAPI app with an endpoint using the enum | |
| app = FastAPI() | |
| def read_item( | |
| item_id: int, | |
| query: QueryEnum | None = None, | |
| ): | |
| return {"item_id": item_id, "query": query} | |
| # Create a client for the app | |
| client = AsyncClient(transport=ASGITransport(app=app), base_url="http://test") | |
| # Create the FastMCPOpenAPI server from the app | |
| openapi_spec = app.openapi() | |
| server = FastMCPOpenAPI( | |
| openapi_spec=openapi_spec, | |
| client=client, | |
| name="Enum Test", | |
| ) | |
| # Get the tools from the server | |
| tools = server._tool_manager.list_tools() | |
| # Find the read_item tool | |
| read_item_tool = next((t for t in tools if t.name == "read_item_items"), None) | |
| # Verify the tool exists | |
| assert read_item_tool is not None, "read_item tool wasn't created" | |
| # Check that the parameters include the enum reference | |
| assert "properties" in read_item_tool.parameters | |
| assert "query" in read_item_tool.parameters["properties"] | |
| # Check for the anyOf with $ref to the enum definition | |
| query_param = read_item_tool.parameters["properties"]["query"] | |
| assert "anyOf" in query_param | |
| # Find the ref in the anyOf list | |
| ref_found = False | |
| for option in query_param["anyOf"]: | |
| if "$ref" in option and option["$ref"].startswith("#/$defs/QueryEnum"): | |
| ref_found = True | |
| break | |
| assert ref_found, "Reference to enum definition not found in query parameter" | |
| # Check that the $defs section exists and contains the enum definition | |
| assert "$defs" in read_item_tool.parameters | |
| assert "QueryEnum" in read_item_tool.parameters["$defs"] | |
| # Verify the enum definition | |
| enum_def = read_item_tool.parameters["$defs"]["QueryEnum"] | |
| assert "enum" in enum_def | |
| assert enum_def["enum"] == ["foo", "bar", "baz"] | |
| assert enum_def["type"] == "string" | |
| class TestRouteMapWildcard: | |
| """Tests for wildcard RouteMap methods functionality.""" | |
| def basic_openapi_spec(self) -> dict: | |
| """Create a minimal OpenAPI spec with different HTTP methods.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "Test API", "version": "1.0.0"}, | |
| "paths": { | |
| "/users": { | |
| "get": { | |
| "operationId": "getUsers", | |
| "responses": {"200": {"description": "Success"}}, | |
| }, | |
| "post": { | |
| "operationId": "createUser", | |
| "responses": {"201": {"description": "Created"}}, | |
| }, | |
| }, | |
| "/posts": { | |
| "get": { | |
| "operationId": "getPosts", | |
| "responses": {"200": {"description": "Success"}}, | |
| }, | |
| "post": { | |
| "operationId": "createPost", | |
| "responses": {"201": {"description": "Created"}}, | |
| }, | |
| }, | |
| }, | |
| } | |
| async def mock_basic_client(self) -> httpx.AsyncClient: | |
| """Create a simple mock client.""" | |
| async def _responder(request): | |
| return httpx.Response(200, json={"status": "ok"}) | |
| transport = httpx.MockTransport(_responder) | |
| return httpx.AsyncClient(transport=transport, base_url="http://test") | |
| async def test_wildcard_matches_all_methods( | |
| self, basic_openapi_spec, mock_basic_client | |
| ): | |
| """Test that a RouteMap with methods='*' matches all HTTP methods.""" | |
| # Create a single route map with wildcard method | |
| route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)] | |
| mcp = FastMCPOpenAPI( | |
| openapi_spec=basic_openapi_spec, | |
| client=mock_basic_client, | |
| route_maps=route_maps, | |
| ) | |
| # All operations should be mapped to tools | |
| tools = mcp._tool_manager.list_tools() | |
| tool_names = {tool.name for tool in tools} | |
| # Check that all 4 operations became tools | |
| expected_tools = {"getUsers", "createUser", "getPosts", "createPost"} | |
| assert tool_names == expected_tools | |
| class TestRouteMapTags: | |
| """Tests for RouteMap tags functionality.""" | |
| def tagged_openapi_spec(self) -> dict: | |
| """Create an OpenAPI spec with various tags for testing.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "Tagged API", "version": "1.0.0"}, | |
| "paths": { | |
| "/users": { | |
| "get": { | |
| "operationId": "getUsers", | |
| "tags": ["users", "public"], | |
| "responses": {"200": {"description": "Success"}}, | |
| }, | |
| "post": { | |
| "operationId": "createUser", | |
| "tags": ["users", "admin"], | |
| "responses": {"201": {"description": "Created"}}, | |
| }, | |
| }, | |
| "/admin/stats": { | |
| "get": { | |
| "operationId": "getAdminStats", | |
| "tags": ["admin", "internal"], | |
| "responses": {"200": {"description": "Success"}}, | |
| } | |
| }, | |
| "/health": { | |
| "get": { | |
| "operationId": "getHealth", | |
| "tags": ["public"], | |
| "responses": {"200": {"description": "Success"}}, | |
| } | |
| }, | |
| "/metrics": { | |
| "get": { | |
| "operationId": "getMetrics", | |
| "responses": {"200": {"description": "Success"}}, | |
| } | |
| }, | |
| }, | |
| } | |
| async def mock_client(self) -> httpx.AsyncClient: | |
| """Create a simple mock client.""" | |
| async def _responder(request): | |
| return httpx.Response(200, json={"status": "ok"}) | |
| transport = httpx.MockTransport(_responder) | |
| return httpx.AsyncClient(transport=transport, base_url="http://test") | |
| async def test_tags_as_tools(self, tagged_openapi_spec, mock_client): | |
| """Test that routes with specific tags are converted to tools.""" | |
| # Convert routes with "admin" tag to tools | |
| route_maps = [ | |
| RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}), | |
| RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), | |
| ] | |
| server = FastMCPOpenAPI( | |
| openapi_spec=tagged_openapi_spec, | |
| client=mock_client, | |
| route_maps=route_maps, | |
| ) | |
| # Check that admin-tagged routes are tools | |
| tools = server._tool_manager.get_tools() | |
| tool_names = {t.name for t in tools.values()} | |
| resources = server._resource_manager.get_resources() | |
| resource_names = {r.name for r in resources.values()} | |
| # Routes with "admin" tag should be tools | |
| assert "createUser" in tool_names | |
| assert "getAdminStats" in tool_names | |
| # Routes without "admin" tag should be resources | |
| assert "getUsers" in resource_names | |
| assert "getHealth" in resource_names | |
| assert "getMetrics" in resource_names | |
| async def test_exclude_tags(self, tagged_openapi_spec, mock_client): | |
| """Test that routes with specific tags are excluded.""" | |
| # Exclude routes with "internal" tag | |
| route_maps = [ | |
| RouteMap( | |
| methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"} | |
| ), | |
| RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), | |
| RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL), | |
| ] | |
| server = FastMCPOpenAPI( | |
| openapi_spec=tagged_openapi_spec, | |
| client=mock_client, | |
| route_maps=route_maps, | |
| ) | |
| # Check that internal-tagged routes are excluded | |
| resources = server._resource_manager.get_resources() | |
| resource_names = {r.name for r in resources.values()} | |
| tools = server._tool_manager.get_tools() | |
| tool_names = {t.name for t in tools.values()} | |
| # Internal-tagged route should be excluded | |
| assert "getAdminStats" not in resource_names | |
| assert "getAdminStats" not in tool_names | |
| # Other routes should still be present | |
| assert "getUsers" in resource_names | |
| assert "getHealth" in resource_names | |
| assert "getMetrics" in resource_names | |
| assert "createUser" in tool_names | |
| async def test_multiple_tags_and_condition(self, tagged_openapi_spec, mock_client): | |
| """Test that routes must have ALL specified tags (AND condition).""" | |
| # Routes must have BOTH "users" AND "admin" tags | |
| route_maps = [ | |
| RouteMap( | |
| methods="*", | |
| pattern=r".*", | |
| mcp_type=MCPType.TOOL, | |
| tags={"users", "admin"}, | |
| ), | |
| RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), | |
| ] | |
| server = FastMCPOpenAPI( | |
| openapi_spec=tagged_openapi_spec, | |
| client=mock_client, | |
| route_maps=route_maps, | |
| ) | |
| tools = server._tool_manager.get_tools() | |
| tool_names = {t.name for t in tools.values()} | |
| resources = server._resource_manager.get_resources() | |
| resource_names = {r.name for r in resources.values()} | |
| # Only createUser has both "users" AND "admin" tags | |
| assert "createUser" in tool_names | |
| # Other routes should be resources | |
| assert "getUsers" in resource_names # has "users" but not "admin" | |
| assert "getAdminStats" in resource_names # has "admin" but not "users" | |
| assert "getHealth" in resource_names | |
| assert "getMetrics" in resource_names | |
| async def test_pattern_and_tags_combination(self, tagged_openapi_spec, mock_client): | |
| """Test that both pattern and tags must be satisfied.""" | |
| # Routes matching pattern AND having specific tags | |
| route_maps = [ | |
| RouteMap( | |
| methods="*", | |
| pattern=r".*/admin/.*", | |
| mcp_type=MCPType.TOOL, | |
| tags={"admin"}, | |
| ), | |
| RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), | |
| RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL), | |
| ] | |
| server = FastMCPOpenAPI( | |
| openapi_spec=tagged_openapi_spec, | |
| client=mock_client, | |
| route_maps=route_maps, | |
| ) | |
| tools = server._tool_manager.get_tools() | |
| tool_names = {t.name for t in tools.values()} | |
| resources = server._resource_manager.get_resources() | |
| resource_names = {r.name for r in resources.values()} | |
| # Only getAdminStats matches both /admin/ pattern AND "admin" tag | |
| assert "getAdminStats" in tool_names | |
| # createUser has "admin" tag but doesn't match pattern, so it becomes a tool via POST rule | |
| assert "createUser" in tool_names | |
| # Other routes should be resources (GET) | |
| assert "getUsers" in resource_names | |
| assert "getHealth" in resource_names | |
| assert "getMetrics" in resource_names | |
| async def test_empty_tags_ignored(self, tagged_openapi_spec, mock_client): | |
| """Test that empty tags set is ignored (matches all routes).""" | |
| # Empty tags should match all routes | |
| route_maps = [ | |
| RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags=set()), | |
| ] | |
| server = FastMCPOpenAPI( | |
| openapi_spec=tagged_openapi_spec, | |
| client=mock_client, | |
| route_maps=route_maps, | |
| ) | |
| tools = server._tool_manager.get_tools() | |
| tool_names = {t.name for t in tools.values()} | |
| # All routes should be tools since empty tags matches everything | |
| expected_tools = { | |
| "getUsers", | |
| "createUser", | |
| "getAdminStats", | |
| "getHealth", | |
| "getMetrics", | |
| } | |
| assert tool_names == expected_tools | |
| class TestMCPNames: | |
| """Tests for the mcp_names dictionary functionality.""" | |
| def mcp_names_openapi_spec(self) -> dict: | |
| """OpenAPI spec with various operationIds for testing naming strategies.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "MCP Names Test API", "version": "1.0.0"}, | |
| "paths": { | |
| "/users": { | |
| "get": { | |
| "operationId": "list_users__with_pagination", | |
| "summary": "Get All Users", | |
| "responses": {"200": {"description": "Success"}}, | |
| }, | |
| "post": { | |
| "operationId": "create_user_admin__special_permissions", | |
| "summary": "Create New User", | |
| "requestBody": { | |
| "required": True, | |
| "content": { | |
| "application/json": { | |
| "schema": { | |
| "type": "object", | |
| "properties": {"name": {"type": "string"}}, | |
| "required": ["name"], | |
| } | |
| } | |
| }, | |
| }, | |
| "responses": {"201": {"description": "Created"}}, | |
| }, | |
| }, | |
| "/users/{id}": { | |
| "get": { | |
| "operationId": "get_user_by_id__admin_only", | |
| "summary": "Fetch Single User Profile", | |
| "parameters": [ | |
| { | |
| "name": "id", | |
| "in": "path", | |
| "required": True, | |
| "schema": {"type": "integer"}, | |
| } | |
| ], | |
| "responses": {"200": {"description": "Success"}}, | |
| } | |
| }, | |
| "/very-long-endpoint-name": { | |
| "get": { | |
| "operationId": "this_is_a_very_long_operation_id_that_exceeds_fifty_six_characters_and_should_be_truncated", | |
| "summary": "This Is A Very Long Summary That Should Also Be Truncated When Used As Name", | |
| "responses": {"200": {"description": "Success"}}, | |
| } | |
| }, | |
| "/special": { | |
| "get": { | |
| "operationId": "special-chars@and#spaces in$operation%id", | |
| "summary": "Special Chars & Spaces In Summary!", | |
| "responses": {"200": {"description": "Success"}}, | |
| } | |
| }, | |
| }, | |
| } | |
| async def mock_client(self) -> httpx.AsyncClient: | |
| """Mock client for testing.""" | |
| async def _responder(request): | |
| return httpx.Response(200, json={"status": "ok"}) | |
| transport = httpx.MockTransport(_responder) | |
| return httpx.AsyncClient(transport=transport, base_url="http://test") | |
| async def test_mcp_names_custom_mapping(self, mcp_names_openapi_spec, mock_client): | |
| """Test that mcp_names dictionary provides custom names for components.""" | |
| mcp_names = { | |
| "list_users__with_pagination": "user_list", | |
| "create_user_admin__special_permissions": "admin_create_user", | |
| "get_user_by_id__admin_only": "user_detail", | |
| } | |
| server = FastMCPOpenAPI( | |
| openapi_spec=mcp_names_openapi_spec, | |
| client=mock_client, | |
| mcp_names=mcp_names, | |
| ) | |
| # Check tools use custom names | |
| tools = server._tool_manager.list_tools() | |
| tool_names = {tool.name for tool in tools} | |
| assert "admin_create_user" in tool_names | |
| # Check resource templates use custom names | |
| templates = list(server._resource_manager.get_templates().values()) | |
| template_names = {template.name for template in templates} | |
| assert "user_detail" in template_names | |
| # Check resources use custom names | |
| resources = list(server._resource_manager.get_resources().values()) | |
| resource_names = {resource.name for resource in resources} | |
| assert "user_list" in resource_names | |
| async def test_mcp_names_fallback_to_operation_id_short( | |
| self, mcp_names_openapi_spec, mock_client | |
| ): | |
| """Test fallback to operationId up to double underscore when not in mcp_names.""" | |
| # Only provide mapping for one operationId | |
| mcp_names = { | |
| "list_users__with_pagination": "custom_user_list", | |
| } | |
| server = FastMCPOpenAPI( | |
| openapi_spec=mcp_names_openapi_spec, | |
| client=mock_client, | |
| mcp_names=mcp_names, | |
| ) | |
| tools = server._tool_manager.list_tools() | |
| tool_names = {tool.name for tool in tools} | |
| templates = list(server._resource_manager.get_templates().values()) | |
| template_names = {template.name for template in templates} | |
| resources = list(server._resource_manager.get_resources().values()) | |
| resource_names = {resource.name for resource in resources} | |
| # Custom mapped name should be used | |
| assert "custom_user_list" in resource_names | |
| # Unmapped operationIds should use short version (up to __) | |
| assert "create_user_admin" in tool_names | |
| assert "get_user_by_id" in template_names | |
| async def test_names_are_slugified(self, mcp_names_openapi_spec, mock_client): | |
| """Test that names are properly slugified (spaces, special chars removed).""" | |
| server = FastMCPOpenAPI( | |
| openapi_spec=mcp_names_openapi_spec, | |
| client=mock_client, | |
| ) | |
| resources = list(server._resource_manager.get_resources().values()) | |
| resource_names = { | |
| resource.name for resource in resources if resource.name is not None | |
| } | |
| # Special chars and spaces should be slugified | |
| slugified_name = next( | |
| (name for name in resource_names if "special" in name), None | |
| ) | |
| assert slugified_name is not None | |
| # Should not contain special characters or spaces | |
| assert "@" not in slugified_name | |
| assert "#" not in slugified_name | |
| assert "$" not in slugified_name | |
| assert "%" not in slugified_name | |
| assert " " not in slugified_name | |
| async def test_names_are_truncated_to_56_chars( | |
| self, mcp_names_openapi_spec, mock_client | |
| ): | |
| """Test that names are truncated to 56 characters maximum.""" | |
| server = FastMCPOpenAPI( | |
| openapi_spec=mcp_names_openapi_spec, | |
| client=mock_client, | |
| ) | |
| # Check all component types | |
| all_names = [] | |
| tools = server._tool_manager.list_tools() | |
| all_names.extend(tool.name for tool in tools) | |
| resources = list(server._resource_manager.get_resources().values()) | |
| all_names.extend(resource.name for resource in resources) | |
| templates = list(server._resource_manager.get_templates().values()) | |
| all_names.extend(template.name for template in templates) | |
| # All names should be 56 characters or less | |
| for name in all_names: | |
| assert len(name) <= 56, ( | |
| f"Name '{name}' exceeds 56 characters (length: {len(name)})" | |
| ) | |
| # Verify that the long operationId was actually truncated | |
| long_name = next((name for name in all_names if len(name) > 50), None) | |
| assert long_name is not None, "Expected to find a truncated name for testing" | |
| async def test_mcp_names_with_from_openapi_classmethod( | |
| self, mcp_names_openapi_spec, mock_client | |
| ): | |
| """Test mcp_names works with FastMCP.from_openapi() classmethod.""" | |
| mcp_names = { | |
| "list_users__with_pagination": "openapi_user_list", | |
| } | |
| server = FastMCP.from_openapi( | |
| openapi_spec=mcp_names_openapi_spec, | |
| client=mock_client, | |
| mcp_names=mcp_names, | |
| ) | |
| resources = list(server._resource_manager.get_resources().values()) | |
| resource_names = {resource.name for resource in resources} | |
| assert "openapi_user_list" in resource_names | |
| async def test_mcp_names_with_from_fastapi_classmethod(self): | |
| """Test mcp_names works with FastMCP.from_fastapi() classmethod.""" | |
| from fastapi import FastAPI | |
| from pydantic import BaseModel | |
| app = FastAPI(title="FastAPI MCP Names Test") | |
| class User(BaseModel): | |
| name: str | |
| async def get_users() -> list[User]: | |
| return [User(name="test")] | |
| async def create_user(user: User) -> User: | |
| return user | |
| mcp_names = { | |
| "list_users__with_filters": "fastapi_user_list", | |
| "create_user__admin_required": "fastapi_create_user", | |
| } | |
| server = FastMCP.from_fastapi( | |
| app=app, | |
| mcp_names=mcp_names, | |
| ) | |
| tools = server._tool_manager.list_tools() | |
| tool_names = {tool.name for tool in tools} | |
| resources = list(server._resource_manager.get_resources().values()) | |
| resource_names = {resource.name for resource in resources} | |
| assert "fastapi_create_user" in tool_names | |
| assert "fastapi_user_list" in resource_names | |
| async def test_mcp_names_custom_names_are_also_truncated( | |
| self, mcp_names_openapi_spec, mock_client | |
| ): | |
| """Test that custom names in mcp_names are also truncated to 56 characters.""" | |
| # Provide a custom name that's longer than 56 characters | |
| very_long_custom_name = "this_is_a_very_long_custom_name_that_exceeds_fifty_six_characters_and_should_be_truncated" | |
| mcp_names = { | |
| "list_users__with_pagination": very_long_custom_name, | |
| } | |
| server = FastMCPOpenAPI( | |
| openapi_spec=mcp_names_openapi_spec, | |
| client=mock_client, | |
| mcp_names=mcp_names, | |
| ) | |
| resources = list(server._resource_manager.get_resources().values()) | |
| resource_names = { | |
| resource.name for resource in resources if resource.name is not None | |
| } | |
| # Find the resource that should have the custom name | |
| truncated_name = next( | |
| ( | |
| name | |
| for name in resource_names | |
| if "this_is_a_very_long_custom_name" in name | |
| ), | |
| None, | |
| ) | |
| assert truncated_name is not None | |
| assert len(truncated_name) <= 56 | |
| assert ( | |
| len(truncated_name) == 56 | |
| ) # Should be exactly 56 since original was longer | |