Skip to content

Commit 7f884cf

Browse files
feat: add ToolFilterMiddleware for per-request tool filtering (#16)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 27fc753 commit 7f884cf

File tree

9 files changed

+1251
-219
lines changed

9 files changed

+1251
-219
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ ignore = ["DEP004"] # Ignore misplaced dev dependencies (common for test framew
9696
# DEP004=dev-package # Ignore misplaced dev dependency
9797
[tool.deptry.per_rule_ignores]
9898
# uvicorn is a transitive dependency through fastmcp, used for HTTP server testing
99-
DEP003 = ["uvicorn"]
99+
# mcp is a transitive dependency through fastmcp, used for MCP protocol types
100+
DEP003 = ["uvicorn", "mcp"]
100101

101102
[dependency-groups]
102103
dev = [

src/fastmcp_extensions/__init__.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,6 @@
1111
- Prompt text retrieval helpers
1212
"""
1313

14-
from fastmcp_extensions.annotations import (
15-
DESTRUCTIVE_HINT,
16-
IDEMPOTENT_HINT,
17-
OPEN_WORLD_HINT,
18-
READ_ONLY_HINT,
19-
)
2014
from fastmcp_extensions.decorators import (
2115
mcp_prompt,
2216
mcp_resource,
@@ -29,22 +23,20 @@
2923
register_mcp_resources,
3024
register_mcp_tools,
3125
)
32-
from fastmcp_extensions.server import (
26+
from fastmcp_extensions.server import mcp_server
27+
from fastmcp_extensions.server_config import (
3328
MCPServerConfig,
3429
MCPServerConfigArg,
3530
get_mcp_config,
36-
mcp_server,
3731
)
32+
from fastmcp_extensions.tool_filters import ToolFilterFn
3833

3934
__all__ = [
40-
"DESTRUCTIVE_HINT",
41-
"IDEMPOTENT_HINT",
42-
"OPEN_WORLD_HINT",
43-
"READ_ONLY_HINT",
4435
"MCPServerConfig",
4536
"MCPServerConfigArg",
4637
"PromptDef",
4738
"ResourceDef",
39+
"ToolFilterFn",
4840
"get_mcp_config",
4941
"mcp_prompt",
5042
"mcp_resource",
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2+
"""Internal module for tool filtering implementation.
3+
4+
This is a private module that provides the internal implementation for
5+
per-request tool filtering. Users should not import from this module directly.
6+
7+
For tool filtering, use the `mcp_server()` function with `tool_filters` or
8+
`include_standard_tool_filters` parameters instead.
9+
10+
See Also:
11+
- FastMCP middleware documentation: https://gofastmcp.com/servers/middleware
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from collections.abc import Callable, Sequence
17+
18+
from fastmcp import FastMCP
19+
from fastmcp.server.middleware import Middleware, MiddlewareContext
20+
from fastmcp.tools.tool import ToolResult
21+
from mcp import types as mt
22+
from mcp.types import Tool
23+
24+
from fastmcp_extensions.tool_filters import ToolFilterFn
25+
26+
27+
class ToolFilterMiddleware(Middleware):
28+
"""Middleware that filters tools on a per-request basis.
29+
30+
This middleware intercepts tool listing and tool calls to filter
31+
which tools are visible and callable based on a user-provided
32+
filter function. The filter function has access to the FastMCP
33+
app, allowing it to use get_mcp_config() to access request-specific
34+
configuration values.
35+
36+
Args:
37+
app: The FastMCP application instance.
38+
tool_filter: A callable that takes (Tool, FastMCP) and returns
39+
True if the tool should be visible, False to hide it.
40+
41+
Example:
42+
```python
43+
def readonly_filter(tool: Tool, app: FastMCP) -> bool:
44+
if get_mcp_config(app, "readonly_mode") == "1":
45+
annotations = tool.annotations
46+
if annotations is None:
47+
return False
48+
return getattr(annotations, "readOnlyHint", False)
49+
return True
50+
51+
52+
middleware = ToolFilterMiddleware(app, tool_filter=readonly_filter)
53+
app.add_middleware(middleware)
54+
```
55+
"""
56+
57+
def __init__(
58+
self,
59+
app: FastMCP,
60+
*,
61+
tool_filter: ToolFilterFn,
62+
) -> None:
63+
"""Initialize the middleware.
64+
65+
Args:
66+
app: The FastMCP application instance.
67+
tool_filter: A callable that determines tool visibility.
68+
"""
69+
self._app = app
70+
self._tool_filter = tool_filter
71+
72+
async def on_list_tools(
73+
self,
74+
context: MiddlewareContext[mt.ListToolsRequest],
75+
call_next: Callable[[MiddlewareContext[mt.ListToolsRequest]], Sequence[Tool]],
76+
) -> Sequence[Tool]:
77+
"""Filter the tool list based on the filter function.
78+
79+
Args:
80+
context: The middleware context.
81+
call_next: The next handler in the chain.
82+
83+
Returns:
84+
Filtered sequence of tools.
85+
"""
86+
tools = await call_next(context)
87+
return [tool for tool in tools if self._tool_filter(tool, self._app)]
88+
89+
async def on_call_tool(
90+
self,
91+
context: MiddlewareContext[mt.CallToolRequestParams],
92+
call_next: Callable[[MiddlewareContext[mt.CallToolRequestParams]], ToolResult],
93+
) -> ToolResult:
94+
"""Deny calls to filtered tools.
95+
96+
Args:
97+
context: The middleware context.
98+
call_next: The next handler in the chain.
99+
100+
Returns:
101+
The tool result if allowed.
102+
103+
Raises:
104+
ValueError: If the tool is filtered out.
105+
"""
106+
tool_name = context.message.name
107+
108+
# Look up the tool to check if it should be filtered
109+
tool = self._get_tool_by_name(tool_name)
110+
if tool is not None and not self._tool_filter(tool, self._app):
111+
raise ValueError(
112+
f"Tool '{tool_name}' is not available. "
113+
"It may be restricted based on your current session configuration."
114+
)
115+
116+
return await call_next(context)
117+
118+
def _get_tool_by_name(self, name: str) -> Tool | None:
119+
"""Look up a tool by name from the app's tool manager.
120+
121+
Args:
122+
name: The tool name to look up.
123+
124+
Returns:
125+
The Tool object if found, None otherwise.
126+
"""
127+
# Access FastMCP's internal tool manager to get tool info
128+
tool_manager = getattr(self._app, "_tool_manager", None)
129+
if tool_manager is None:
130+
return None
131+
132+
# Access the private _tools dict (the public methods are async)
133+
tools = getattr(tool_manager, "_tools", {})
134+
fast_tool = tools.get(name)
135+
if fast_tool is None:
136+
return None
137+
138+
# Convert FastTool to MCP Tool type
139+
return fast_tool.to_mcp_tool()

0 commit comments

Comments
 (0)