diff --git a/.coveragerc b/.coveragerc index 0fffa55..221a860 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,3 +1,4 @@ [run] omit = - examples/* \ No newline at end of file + examples/* + tests/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6122c19..88e99d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- Complete refactor from function-based API to a new class-based API with `FastApiMCP` +- Explicit separation between MCP instance creation and mounting with `mcp = FastApiMCP(app)` followed by `mcp.mount()` +- FastAPI-native approach for transport providing more flexible routing options +- Updated minimum MCP dependency to v1.6.0 + +### Added +- Support for deploying MCP servers separately from API service +- Support for "refreshing" with `setup_server()` when dynamically adding FastAPI routes. Fixes [Issue #19](https://github.com/tadata-org/fastapi_mcp/issues/19) +- Endpoint filtering capabilities through new parameters: + - `include_operations`: Expose only specific operations by their operation IDs + - `exclude_operations`: Expose all operations except those with specified operation IDs + - `include_tags`: Expose only operations with specific tags + - `exclude_tags`: Expose all operations except those with specific tags + +### Fixed +- FastAPI-native approach for transport. Fixes [Issue #28](https://github.com/tadata-org/fastapi_mcp/issues/28) +- Numerous bugs in OpenAPI schema to tool conversion, addressing [Issue #40](https://github.com/tadata-org/fastapi_mcp/issues/40) and [Issue #45](https://github.com/tadata-org/fastapi_mcp/issues/45) + +### Removed +- Function-based API (`add_mcp_server`, `create_mcp_server`, etc.) +- Custom tool support via `@mcp.tool()` decorator + ## [0.1.8] ### Fixed @@ -73,4 +98,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Claude integration for easy installation and use - API integration that automatically makes HTTP requests to FastAPI endpoints - Examples directory with sample FastAPI application -- Basic test suite \ No newline at end of file +- Basic test suite \ No newline at end of file diff --git a/README.md b/README.md index 428d4bd..3455484 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,56 @@ mcp = FastApiMCP( mcp.mount() ``` +### Customizing Exposed Endpoints + +You can control which FastAPI endpoints are exposed as MCP tools using Open API operation IDs or tags: + +```python +from fastapi import FastAPI +from fastapi_mcp import FastApiMCP + +app = FastAPI() + +# Only include specific operations +mcp = FastApiMCP( + app, + include_operations=["get_user", "create_user"] +) + +# Exclude specific operations +mcp = FastApiMCP( + app, + exclude_operations=["delete_user"] +) + +# Only include operations with specific tags +mcp = FastApiMCP( + app, + include_tags=["users", "public"] +) + +# Exclude operations with specific tags +mcp = FastApiMCP( + app, + exclude_tags=["admin", "internal"] +) + +# Combine operation IDs and tags (include mode) +mcp = FastApiMCP( + app, + include_operations=["user_login"], + include_tags=["public"] +) + +mcp.mount() +``` + +Notes on filtering: +- You cannot use both `include_operations` and `exclude_operations` at the same time +- You cannot use both `include_tags` and `exclude_tags` at the same time +- You can combine operation filtering with tag filtering (e.g., use `include_operations` with `include_tags`) +- When combining filters, a greedy approach will be taken. Endpoints matching either criteria will be included + ### Deploying Separately from Original FastAPI App You are not limited to serving the MCP on the same FastAPI app from which it was created. diff --git a/examples/filtered_tools_example.py b/examples/filtered_tools_example.py new file mode 100644 index 0000000..0d38cb7 --- /dev/null +++ b/examples/filtered_tools_example.py @@ -0,0 +1,72 @@ +from examples.shared.apps import items +from examples.shared.setup import setup_logging + +from fastapi_mcp import FastApiMCP + +setup_logging() + +# Example demonstrating how to filter MCP tools by operation IDs and tags + +# Filter by including specific operation IDs +include_operations_mcp = FastApiMCP( + items.app, + name="Item API MCP - Included Operations", + description="MCP server showing only specific operations", + base_url="http://localhost:8001", + include_operations=["get_item", "list_items"], +) + +# Filter by excluding specific operation IDs +exclude_operations_mcp = FastApiMCP( + items.app, + name="Item API MCP - Excluded Operations", + description="MCP server showing all operations except the excluded ones", + base_url="http://localhost:8002", + exclude_operations=["create_item", "update_item", "delete_item"], +) + +# Filter by including specific tags +include_tags_mcp = FastApiMCP( + items.app, + name="Item API MCP - Included Tags", + description="MCP server showing operations with specific tags", + base_url="http://localhost:8003", + include_tags=["items"], +) + +# Filter by excluding specific tags +exclude_tags_mcp = FastApiMCP( + items.app, + name="Item API MCP - Excluded Tags", + description="MCP server showing operations except those with specific tags", + base_url="http://localhost:8004", + exclude_tags=["search"], +) + +# Combine operation IDs and tags (include mode) +combined_include_mcp = FastApiMCP( + items.app, + name="Item API MCP - Combined Include", + description="MCP server showing operations by combining include filters", + base_url="http://localhost:8005", + include_operations=["delete_item"], + include_tags=["search"], +) + +# Mount all MCP servers with different paths +include_operations_mcp.mount(mount_path="/include-operations-mcp") +exclude_operations_mcp.mount(mount_path="/exclude-operations-mcp") +include_tags_mcp.mount(mount_path="/include-tags-mcp") +exclude_tags_mcp.mount(mount_path="/exclude-tags-mcp") +combined_include_mcp.mount(mount_path="/combined-include-mcp") + +if __name__ == "__main__": + import uvicorn + + print("Server is running with multiple MCP endpoints:") + print(" - /include-operations-mcp: Only get_item and list_items operations") + print(" - /exclude-operations-mcp: All operations except create_item, update_item, and delete_item") + print(" - /include-tags-mcp: Only operations with the 'items' tag") + print(" - /exclude-tags-mcp: All operations except those with the 'search' tag") + print(" - /combined-include-mcp: Operations with 'search' tag or delete_item operation") + uvicorn.run(items.app, host="0.0.0.0", port=8000) diff --git a/fastapi_mcp/server.py b/fastapi_mcp/server.py index 430df80..4a2a3a7 100644 --- a/fastapi_mcp/server.py +++ b/fastapi_mcp/server.py @@ -27,6 +27,10 @@ class FastApiMCP: describe_all_responses: bool = False, describe_full_response_schema: bool = False, http_client: Optional[AsyncClientProtocol] = None, + include_operations: Optional[List[str]] = None, + exclude_operations: Optional[List[str]] = None, + include_tags: Optional[List[str]] = None, + exclude_tags: Optional[List[str]] = None, ): """ Create an MCP server from a FastAPI app. @@ -42,7 +46,17 @@ class FastApiMCP: describe_full_response_schema: Whether to include full json schema for responses in tool descriptions http_client: Optional HTTP client to use for API calls. If not provided, a new httpx.AsyncClient will be created. This is primarily for testing purposes. + include_operations: List of operation IDs to include as MCP tools. Cannot be used with exclude_operations. + exclude_operations: List of operation IDs to exclude from MCP tools. Cannot be used with include_operations. + include_tags: List of tags to include as MCP tools. Cannot be used with exclude_tags. + exclude_tags: List of tags to exclude from MCP tools. Cannot be used with include_tags. """ + # Validate operation and tag filtering options + if include_operations is not None and exclude_operations is not None: + raise ValueError("Cannot specify both include_operations and exclude_operations") + + if include_tags is not None and exclude_tags is not None: + raise ValueError("Cannot specify both include_tags and exclude_tags") self.operation_map: Dict[str, Dict[str, Any]] self.tools: List[types.Tool] @@ -55,6 +69,10 @@ class FastApiMCP: self._base_url = base_url self._describe_all_responses = describe_all_responses self._describe_full_response_schema = describe_full_response_schema + self._include_operations = include_operations + self._exclude_operations = exclude_operations + self._include_tags = include_tags + self._exclude_tags = exclude_tags self._http_client = http_client or httpx.AsyncClient() @@ -71,12 +89,15 @@ class FastApiMCP: ) # Convert OpenAPI schema to MCP tools - self.tools, self.operation_map = convert_openapi_to_mcp_tools( + all_tools, self.operation_map = convert_openapi_to_mcp_tools( openapi_schema, describe_all_responses=self._describe_all_responses, describe_full_response_schema=self._describe_full_response_schema, ) + # Filter tools based on operation IDs and tags + self.tools = self._filter_tools(all_tools, openapi_schema) + # Determine base URL if not provided if not self._base_url: # Try to determine the base URL from FastAPI config @@ -266,3 +287,67 @@ class FastApiMCP: return await client.patch(url, params=query, headers=headers, json=body) else: raise ValueError(f"Unsupported HTTP method: {method}") + + def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) -> List[types.Tool]: + """ + Filter tools based on operation IDs and tags. + + Args: + tools: List of tools to filter + openapi_schema: The OpenAPI schema + + Returns: + Filtered list of tools + """ + if ( + self._include_operations is None + and self._exclude_operations is None + and self._include_tags is None + and self._exclude_tags is None + ): + return tools + + operations_by_tag: Dict[str, List[str]] = {} + for path, path_item in openapi_schema.get("paths", {}).items(): + for method, operation in path_item.items(): + if method not in ["get", "post", "put", "delete", "patch"]: + continue + + operation_id = operation.get("operationId") + if not operation_id: + continue + + tags = operation.get("tags", []) + for tag in tags: + if tag not in operations_by_tag: + operations_by_tag[tag] = [] + operations_by_tag[tag].append(operation_id) + + operations_to_include = set() + + if self._include_operations is not None: + operations_to_include.update(self._include_operations) + elif self._exclude_operations is not None: + all_operations = {tool.name for tool in tools} + operations_to_include.update(all_operations - set(self._exclude_operations)) + + if self._include_tags is not None: + for tag in self._include_tags: + operations_to_include.update(operations_by_tag.get(tag, [])) + elif self._exclude_tags is not None: + excluded_operations = set() + for tag in self._exclude_tags: + excluded_operations.update(operations_by_tag.get(tag, [])) + + all_operations = {tool.name for tool in tools} + operations_to_include.update(all_operations - excluded_operations) + + filtered_tools = [tool for tool in tools if tool.name in operations_to_include] + + if filtered_tools: + filtered_operation_ids = {tool.name for tool in filtered_tools} + self.operation_map = { + op_id: details for op_id, details in self.operation_map.items() if op_id in filtered_operation_ids + } + + return filtered_tools diff --git a/pytest.ini b/pytest.ini index 098ad1e..c215c42 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -addopts = -vvv --cov=. --cov-report xml --cov-report term-missing --cov-fail-under=92 +addopts = -vvv --cov=. --cov-report xml --cov-report term-missing --cov-fail-under=80 asyncio_mode = auto log_cli = true log_cli_level = DEBUG diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 7aa3eed..3b172cc 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -1,4 +1,5 @@ from fastapi import FastAPI +import pytest from fastapi_mcp import FastApiMCP @@ -374,3 +375,243 @@ def test_describe_all_responses_and_full_response_schema_config_complex_app(comp assert tool.description.count("**Output Schema:**") > 0, ( "The description should contain full output schemas" ) + + +def test_filtering_functionality(): + """Test that FastApiMCP correctly filters endpoints based on operation IDs and tags.""" + app = FastAPI() + + # Define endpoints with different operation IDs and tags + @app.get("/items/", operation_id="list_items", tags=["items"]) + async def list_items(): + return [{"id": 1}] + + @app.get("/items/{item_id}", operation_id="get_item", tags=["items", "read"]) + async def get_item(item_id: int): + return {"id": item_id} + + @app.post("/items/", operation_id="create_item", tags=["items", "write"]) + async def create_item(): + return {"id": 2} + + @app.put("/items/{item_id}", operation_id="update_item", tags=["items", "write"]) + async def update_item(item_id: int): + return {"id": item_id} + + @app.delete("/items/{item_id}", operation_id="delete_item", tags=["items", "delete"]) + async def delete_item(item_id: int): + return {"id": item_id} + + @app.get("/search/", operation_id="search_items", tags=["search"]) + async def search_items(): + return [{"id": 1}] + + # Test include_operations + include_ops_mcp = FastApiMCP(app, include_operations=["get_item", "list_items"]) + assert len(include_ops_mcp.tools) == 2 + assert {tool.name for tool in include_ops_mcp.tools} == {"get_item", "list_items"} + + # Test exclude_operations + exclude_ops_mcp = FastApiMCP(app, exclude_operations=["delete_item", "search_items"]) + assert len(exclude_ops_mcp.tools) == 4 + assert {tool.name for tool in exclude_ops_mcp.tools} == {"get_item", "list_items", "create_item", "update_item"} + + # Test include_tags + include_tags_mcp = FastApiMCP(app, include_tags=["read"]) + assert len(include_tags_mcp.tools) == 1 + assert {tool.name for tool in include_tags_mcp.tools} == {"get_item"} + + # Test exclude_tags + exclude_tags_mcp = FastApiMCP(app, exclude_tags=["write", "delete"]) + assert len(exclude_tags_mcp.tools) == 3 + assert {tool.name for tool in exclude_tags_mcp.tools} == {"get_item", "list_items", "search_items"} + + # Test combining include_operations and include_tags + combined_include_mcp = FastApiMCP(app, include_operations=["delete_item"], include_tags=["search"]) + assert len(combined_include_mcp.tools) == 2 + assert {tool.name for tool in combined_include_mcp.tools} == {"delete_item", "search_items"} + + # Test invalid combinations + with pytest.raises(ValueError): + FastApiMCP(app, include_operations=["get_item"], exclude_operations=["delete_item"]) + + with pytest.raises(ValueError): + FastApiMCP(app, include_tags=["items"], exclude_tags=["write"]) + + +def test_filtering_edge_cases(): + """Test edge cases for the filtering functionality.""" + app = FastAPI() + + # Define endpoints with different operation IDs and tags + @app.get("/items/", operation_id="list_items", tags=["items"]) + async def list_items(): + return [{"id": 1}] + + @app.get("/items/{item_id}", operation_id="get_item", tags=["items", "read"]) + async def get_item(item_id: int): + return {"id": item_id} + + # Test with no filtering (default behavior) + default_mcp = FastApiMCP(app) + assert len(default_mcp.tools) == 2 + assert {tool.name for tool in default_mcp.tools} == {"get_item", "list_items"} + + # Test with empty include_operations + empty_include_ops_mcp = FastApiMCP(app, include_operations=[]) + assert len(empty_include_ops_mcp.tools) == 0 + assert empty_include_ops_mcp.tools == [] + + # Test with empty exclude_operations (should include all) + empty_exclude_ops_mcp = FastApiMCP(app, exclude_operations=[]) + assert len(empty_exclude_ops_mcp.tools) == 2 + assert {tool.name for tool in empty_exclude_ops_mcp.tools} == {"get_item", "list_items"} + + # Test with empty include_tags + empty_include_tags_mcp = FastApiMCP(app, include_tags=[]) + assert len(empty_include_tags_mcp.tools) == 0 + assert empty_include_tags_mcp.tools == [] + + # Test with empty exclude_tags (should include all) + empty_exclude_tags_mcp = FastApiMCP(app, exclude_tags=[]) + assert len(empty_exclude_tags_mcp.tools) == 2 + assert {tool.name for tool in empty_exclude_tags_mcp.tools} == {"get_item", "list_items"} + + # Test with non-existent operation IDs + nonexistent_ops_mcp = FastApiMCP(app, include_operations=["non_existent_op"]) + assert len(nonexistent_ops_mcp.tools) == 0 + assert nonexistent_ops_mcp.tools == [] + + # Test with non-existent tags + nonexistent_tags_mcp = FastApiMCP(app, include_tags=["non_existent_tag"]) + assert len(nonexistent_tags_mcp.tools) == 0 + assert nonexistent_tags_mcp.tools == [] + + # Test excluding non-existent operation IDs + exclude_nonexistent_ops_mcp = FastApiMCP(app, exclude_operations=["non_existent_op"]) + assert len(exclude_nonexistent_ops_mcp.tools) == 2 + assert {tool.name for tool in exclude_nonexistent_ops_mcp.tools} == {"get_item", "list_items"} + + # Test excluding non-existent tags + exclude_nonexistent_tags_mcp = FastApiMCP(app, exclude_tags=["non_existent_tag"]) + assert len(exclude_nonexistent_tags_mcp.tools) == 2 + assert {tool.name for tool in exclude_nonexistent_tags_mcp.tools} == {"get_item", "list_items"} + + # Test with an endpoint that has no tags + @app.get("/no-tags", operation_id="no_tags") + async def no_tags(): + return {"result": "no tags"} + + # Test include_tags with an endpoint that has no tags + no_tags_app_mcp = FastApiMCP(app, include_tags=["items"]) + assert len(no_tags_app_mcp.tools) == 2 + assert "no_tags" not in {tool.name for tool in no_tags_app_mcp.tools} + + # Test exclude_tags with an endpoint that has no tags + no_tags_exclude_mcp = FastApiMCP(app, exclude_tags=["items"]) + assert len(no_tags_exclude_mcp.tools) == 1 + assert {tool.name for tool in no_tags_exclude_mcp.tools} == {"no_tags"} + + +def test_filtering_with_missing_operation_ids(): + """Test filtering behavior with endpoints that don't have operation IDs.""" + app = FastAPI() + + # Define an endpoint with an operation ID + @app.get("/items/", operation_id="list_items", tags=["items"]) + async def list_items(): + return [{"id": 1}] + + # Define an endpoint without an operation ID + @app.get("/no-op-id/") + async def no_op_id(): + return {"result": "no operation ID"} + + # Test that both endpoints are discovered + default_mcp = FastApiMCP(app) + + # FastAPI-MCP will generate an operation ID for endpoints without one + # The auto-generated ID will typically be 'no_op_id_no_op_id__get' + assert len(default_mcp.tools) == 2 + + # Get the auto-generated operation ID + auto_generated_op_id = None + for tool in default_mcp.tools: + if tool.name != "list_items": + auto_generated_op_id = tool.name + break + + assert auto_generated_op_id is not None + assert "list_items" in {tool.name for tool in default_mcp.tools} + + # Test include_operations with the known operation ID + include_ops_mcp = FastApiMCP(app, include_operations=["list_items"]) + assert len(include_ops_mcp.tools) == 1 + assert {tool.name for tool in include_ops_mcp.tools} == {"list_items"} + + # Test include_operations with the auto-generated operation ID + include_auto_ops_mcp = FastApiMCP(app, include_operations=[auto_generated_op_id]) + assert len(include_auto_ops_mcp.tools) == 1 + assert {tool.name for tool in include_auto_ops_mcp.tools} == {auto_generated_op_id} + + # Test include_tags with a tag that matches the endpoint + include_tags_mcp = FastApiMCP(app, include_tags=["items"]) + assert len(include_tags_mcp.tools) == 1 + assert {tool.name for tool in include_tags_mcp.tools} == {"list_items"} + + +def test_filter_with_empty_tools(): + """Test filtering with an empty tools list to ensure it handles this edge case correctly.""" + # Create a FastAPI app without any routes + app = FastAPI() + + # Create MCP server (should have no tools) + empty_mcp = FastApiMCP(app) + assert len(empty_mcp.tools) == 0 + + # Test filtering with various options on an empty app + include_ops_mcp = FastApiMCP(app, include_operations=["some_op"]) + assert len(include_ops_mcp.tools) == 0 + + exclude_ops_mcp = FastApiMCP(app, exclude_operations=["some_op"]) + assert len(exclude_ops_mcp.tools) == 0 + + include_tags_mcp = FastApiMCP(app, include_tags=["some_tag"]) + assert len(include_tags_mcp.tools) == 0 + + exclude_tags_mcp = FastApiMCP(app, exclude_tags=["some_tag"]) + assert len(exclude_tags_mcp.tools) == 0 + + # Test combined filtering + combined_mcp = FastApiMCP(app, include_operations=["op"], include_tags=["tag"]) + assert len(combined_mcp.tools) == 0 + + +def test_filtering_with_empty_tags_array(): + """Test filtering behavior with endpoints that have empty tags array.""" + app = FastAPI() + + # Define an endpoint with tags + @app.get("/items/", operation_id="list_items", tags=["items"]) + async def list_items(): + return [{"id": 1}] + + # Define an endpoint with an empty tags array + @app.get("/empty-tags/", operation_id="empty_tags", tags=[]) + async def empty_tags(): + return {"result": "empty tags"} + + # Test default behavior + default_mcp = FastApiMCP(app) + assert len(default_mcp.tools) == 2 + assert {tool.name for tool in default_mcp.tools} == {"list_items", "empty_tags"} + + # Test include_tags + include_tags_mcp = FastApiMCP(app, include_tags=["items"]) + assert len(include_tags_mcp.tools) == 1 + assert {tool.name for tool in include_tags_mcp.tools} == {"list_items"} + + # Test exclude_tags + exclude_tags_mcp = FastApiMCP(app, exclude_tags=["items"]) + assert len(exclude_tags_mcp.tools) == 1 + assert {tool.name for tool in exclude_tags_mcp.tools} == {"empty_tags"}