Skip to content

Commit 3daab18

Browse files
committed
add tool filtering capability
1 parent ba9897e commit 3daab18

File tree

3 files changed

+221
-1
lines changed

3 files changed

+221
-1
lines changed

examples/filtered_tools_example.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from examples.shared.apps import items
2+
from examples.shared.setup import setup_logging
3+
4+
from fastapi_mcp import FastApiMCP
5+
6+
setup_logging()
7+
8+
# Example demonstrating how to filter MCP tools by operation IDs and tags
9+
10+
# Filter by including specific operation IDs
11+
include_operations_mcp = FastApiMCP(
12+
items.app,
13+
name="Item API MCP - Included Operations",
14+
description="MCP server showing only specific operations",
15+
base_url="http://localhost:8001",
16+
include_operations=["get_item", "list_items"],
17+
)
18+
19+
# Filter by excluding specific operation IDs
20+
exclude_operations_mcp = FastApiMCP(
21+
items.app,
22+
name="Item API MCP - Excluded Operations",
23+
description="MCP server showing all operations except the excluded ones",
24+
base_url="http://localhost:8002",
25+
exclude_operations=["create_item", "update_item", "delete_item"],
26+
)
27+
28+
# Filter by including specific tags
29+
include_tags_mcp = FastApiMCP(
30+
items.app,
31+
name="Item API MCP - Included Tags",
32+
description="MCP server showing operations with specific tags",
33+
base_url="http://localhost:8003",
34+
include_tags=["items"],
35+
)
36+
37+
# Filter by excluding specific tags
38+
exclude_tags_mcp = FastApiMCP(
39+
items.app,
40+
name="Item API MCP - Excluded Tags",
41+
description="MCP server showing operations except those with specific tags",
42+
base_url="http://localhost:8004",
43+
exclude_tags=["search"],
44+
)
45+
46+
# Combine operation IDs and tags (include mode)
47+
combined_include_mcp = FastApiMCP(
48+
items.app,
49+
name="Item API MCP - Combined Include",
50+
description="MCP server showing operations by combining include filters",
51+
base_url="http://localhost:8005",
52+
include_operations=["delete_item"],
53+
include_tags=["search"],
54+
)
55+
56+
# Mount all MCP servers with different paths
57+
include_operations_mcp.mount(mount_path="/include-operations-mcp")
58+
exclude_operations_mcp.mount(mount_path="/exclude-operations-mcp")
59+
include_tags_mcp.mount(mount_path="/include-tags-mcp")
60+
exclude_tags_mcp.mount(mount_path="/exclude-tags-mcp")
61+
combined_include_mcp.mount(mount_path="/combined-include-mcp")
62+
63+
if __name__ == "__main__":
64+
import uvicorn
65+
66+
print("Server is running with multiple MCP endpoints:")
67+
print(" - /include-operations-mcp: Only get_item and list_items operations")
68+
print(" - /exclude-operations-mcp: All operations except create_item, update_item, and delete_item")
69+
print(" - /include-tags-mcp: Only operations with the 'items' tag")
70+
print(" - /exclude-tags-mcp: All operations except those with the 'search' tag")
71+
print(" - /combined-include-mcp: Operations with 'search' tag or delete_item operation")
72+
uvicorn.run(items.app, host="0.0.0.0", port=8000)

fastapi_mcp/server.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ def __init__(
2727
describe_all_responses: bool = False,
2828
describe_full_response_schema: bool = False,
2929
http_client: Optional[AsyncClientProtocol] = None,
30+
include_operations: Optional[List[str]] = None,
31+
exclude_operations: Optional[List[str]] = None,
32+
include_tags: Optional[List[str]] = None,
33+
exclude_tags: Optional[List[str]] = None,
3034
):
3135
"""
3236
Create an MCP server from a FastAPI app.
@@ -42,7 +46,17 @@ def __init__(
4246
describe_full_response_schema: Whether to include full json schema for responses in tool descriptions
4347
http_client: Optional HTTP client to use for API calls. If not provided, a new httpx.AsyncClient will be created.
4448
This is primarily for testing purposes.
49+
include_operations: List of operation IDs to include as MCP tools. Cannot be used with exclude_operations.
50+
exclude_operations: List of operation IDs to exclude from MCP tools. Cannot be used with include_operations.
51+
include_tags: List of tags to include as MCP tools. Cannot be used with exclude_tags.
52+
exclude_tags: List of tags to exclude from MCP tools. Cannot be used with include_tags.
4553
"""
54+
# Validate operation and tag filtering options
55+
if include_operations is not None and exclude_operations is not None:
56+
raise ValueError("Cannot specify both include_operations and exclude_operations")
57+
58+
if include_tags is not None and exclude_tags is not None:
59+
raise ValueError("Cannot specify both include_tags and exclude_tags")
4660

4761
self.operation_map: Dict[str, Dict[str, Any]]
4862
self.tools: List[types.Tool]
@@ -55,6 +69,10 @@ def __init__(
5569
self._base_url = base_url
5670
self._describe_all_responses = describe_all_responses
5771
self._describe_full_response_schema = describe_full_response_schema
72+
self._include_operations = include_operations
73+
self._exclude_operations = exclude_operations
74+
self._include_tags = include_tags
75+
self._exclude_tags = exclude_tags
5876

5977
self._http_client = http_client or httpx.AsyncClient()
6078

@@ -71,12 +89,15 @@ def setup_server(self) -> None:
7189
)
7290

7391
# Convert OpenAPI schema to MCP tools
74-
self.tools, self.operation_map = convert_openapi_to_mcp_tools(
92+
all_tools, self.operation_map = convert_openapi_to_mcp_tools(
7593
openapi_schema,
7694
describe_all_responses=self._describe_all_responses,
7795
describe_full_response_schema=self._describe_full_response_schema,
7896
)
7997

98+
# Filter tools based on operation IDs and tags
99+
self.tools = self._filter_tools(all_tools, openapi_schema)
100+
80101
# Determine base URL if not provided
81102
if not self._base_url:
82103
# Try to determine the base URL from FastAPI config
@@ -266,3 +287,67 @@ async def _request(
266287
return await client.patch(url, params=query, headers=headers, json=body)
267288
else:
268289
raise ValueError(f"Unsupported HTTP method: {method}")
290+
291+
def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) -> List[types.Tool]:
292+
"""
293+
Filter tools based on operation IDs and tags.
294+
295+
Args:
296+
tools: List of tools to filter
297+
openapi_schema: The OpenAPI schema
298+
299+
Returns:
300+
Filtered list of tools
301+
"""
302+
if (
303+
self._include_operations is None
304+
and self._exclude_operations is None
305+
and self._include_tags is None
306+
and self._exclude_tags is None
307+
):
308+
return tools
309+
310+
operations_by_tag: Dict[str, List[str]] = {}
311+
for path, path_item in openapi_schema.get("paths", {}).items():
312+
for method, operation in path_item.items():
313+
if method not in ["get", "post", "put", "delete", "patch"]:
314+
continue
315+
316+
operation_id = operation.get("operationId")
317+
if not operation_id:
318+
continue
319+
320+
tags = operation.get("tags", [])
321+
for tag in tags:
322+
if tag not in operations_by_tag:
323+
operations_by_tag[tag] = []
324+
operations_by_tag[tag].append(operation_id)
325+
326+
operations_to_include = set()
327+
328+
if self._include_operations is not None:
329+
operations_to_include.update(self._include_operations)
330+
elif self._exclude_operations is not None:
331+
all_operations = {tool.name for tool in tools}
332+
operations_to_include.update(all_operations - set(self._exclude_operations))
333+
334+
if self._include_tags is not None:
335+
for tag in self._include_tags:
336+
operations_to_include.update(operations_by_tag.get(tag, []))
337+
elif self._exclude_tags is not None:
338+
excluded_operations = set()
339+
for tag in self._exclude_tags:
340+
excluded_operations.update(operations_by_tag.get(tag, []))
341+
342+
all_operations = {tool.name for tool in tools}
343+
operations_to_include.update(all_operations - excluded_operations)
344+
345+
filtered_tools = [tool for tool in tools if tool.name in operations_to_include]
346+
347+
if filtered_tools:
348+
filtered_operation_ids = {tool.name for tool in filtered_tools}
349+
self.operation_map = {
350+
op_id: details for op_id, details in self.operation_map.items() if op_id in filtered_operation_ids
351+
}
352+
353+
return filtered_tools

tests/test_configuration.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from fastapi import FastAPI
2+
import pytest
23

34
from fastapi_mcp import FastApiMCP
45

@@ -374,3 +375,65 @@ def test_describe_all_responses_and_full_response_schema_config_complex_app(comp
374375
assert tool.description.count("**Output Schema:**") > 0, (
375376
"The description should contain full output schemas"
376377
)
378+
379+
380+
def test_filtering_functionality():
381+
"""Test that FastApiMCP correctly filters endpoints based on operation IDs and tags."""
382+
app = FastAPI()
383+
384+
# Define endpoints with different operation IDs and tags
385+
@app.get("/items/", operation_id="list_items", tags=["items"])
386+
async def list_items():
387+
return [{"id": 1}]
388+
389+
@app.get("/items/{item_id}", operation_id="get_item", tags=["items", "read"])
390+
async def get_item(item_id: int):
391+
return {"id": item_id}
392+
393+
@app.post("/items/", operation_id="create_item", tags=["items", "write"])
394+
async def create_item():
395+
return {"id": 2}
396+
397+
@app.put("/items/{item_id}", operation_id="update_item", tags=["items", "write"])
398+
async def update_item(item_id: int):
399+
return {"id": item_id}
400+
401+
@app.delete("/items/{item_id}", operation_id="delete_item", tags=["items", "delete"])
402+
async def delete_item(item_id: int):
403+
return {"id": item_id}
404+
405+
@app.get("/search/", operation_id="search_items", tags=["search"])
406+
async def search_items():
407+
return [{"id": 1}]
408+
409+
# Test include_operations
410+
include_ops_mcp = FastApiMCP(app, include_operations=["get_item", "list_items"])
411+
assert len(include_ops_mcp.tools) == 2
412+
assert {tool.name for tool in include_ops_mcp.tools} == {"get_item", "list_items"}
413+
414+
# Test exclude_operations
415+
exclude_ops_mcp = FastApiMCP(app, exclude_operations=["delete_item", "search_items"])
416+
assert len(exclude_ops_mcp.tools) == 4
417+
assert {tool.name for tool in exclude_ops_mcp.tools} == {"get_item", "list_items", "create_item", "update_item"}
418+
419+
# Test include_tags
420+
include_tags_mcp = FastApiMCP(app, include_tags=["read"])
421+
assert len(include_tags_mcp.tools) == 1
422+
assert {tool.name for tool in include_tags_mcp.tools} == {"get_item"}
423+
424+
# Test exclude_tags
425+
exclude_tags_mcp = FastApiMCP(app, exclude_tags=["write", "delete"])
426+
assert len(exclude_tags_mcp.tools) == 3
427+
assert {tool.name for tool in exclude_tags_mcp.tools} == {"get_item", "list_items", "search_items"}
428+
429+
# Test combining include_operations and include_tags
430+
combined_include_mcp = FastApiMCP(app, include_operations=["delete_item"], include_tags=["search"])
431+
assert len(combined_include_mcp.tools) == 2
432+
assert {tool.name for tool in combined_include_mcp.tools} == {"delete_item", "search_items"}
433+
434+
# Test invalid combinations
435+
with pytest.raises(ValueError):
436+
FastApiMCP(app, include_operations=["get_item"], exclude_operations=["delete_item"])
437+
438+
with pytest.raises(ValueError):
439+
FastApiMCP(app, include_tags=["items"], exclude_tags=["write"])

0 commit comments

Comments
 (0)