Skip to content

feat: add ToolFilterMiddleware for per-request tool filtering#16

Merged
Aaron ("AJ") Steers (aaronsteers) merged 8 commits intomainfrom
devin/1768856778-middleware-tool-filtering
Jan 19, 2026
Merged

feat: add ToolFilterMiddleware for per-request tool filtering#16
Aaron ("AJ") Steers (aaronsteers) merged 8 commits intomainfrom
devin/1768856778-middleware-tool-filtering

Conversation

@aaronsteers
Copy link
Contributor

@aaronsteers Aaron ("AJ") Steers (aaronsteers) commented Jan 19, 2026

Summary

Adds middleware-based per-request tool filtering using FastMCP's middleware hooks. This allows different MCP clients to see different tools based on their HTTP headers or other request-specific context.

Key components:

  • ToolFilterMiddleware class with on_list_tools and on_call_tool hooks
  • ToolFilterFn type alias: Callable[[Tool, FastMCP], bool]
  • Filter functions can use get_mcp_config(app, "config_name") to access request-specific configuration values
  • tool_filters parameter on mcp_server() for convenient middleware registration
  • include_standard_tool_filters parameter for one-line setup of common filtering patterns

This replaces the previous registration-time filtering approach (from PR #15's earlier iteration) with a middleware-based approach that supports per-request filtering based on HTTP headers.

Updates since last revision

Extracted server_config.py module to break circular dependency:

  • Created server_config.py with MCPServerConfigArg, MCPServerConfig, and get_mcp_config
  • Moved ToolFilterFn from _middleware.py to tool_filters.py (now possible without circular import)
  • Updated imports in server.py, tool_filters.py, _middleware.py, and __init__.py
  • Updated test patches to reference server_config.get_http_headers instead of server.get_http_headers
  • Removes the circular dependency: tool_filters.py no longer imports from server.py

Previous refactoring (still applies):

  • Created tool_filters.py with all constants, config args, and public filter functions
  • Renamed middleware.py to _middleware.py to hide implementation details from public API
  • Exported public constants for config names, env vars, HTTP headers, and annotation keys
  • Removed ToolFilterMiddleware from public exports (users should use mcp_server() with tool_filters parameter)

When include_standard_tool_filters=True, the following standard filters are added:

Config Name Env Var HTTP Header Behavior
readonly_mode MCP_READONLY_MODE X-MCP-Readonly-Mode When "1", only show tools with readOnlyHint=True
no_destructive_tools MCP_NO_DESTRUCTIVE_TOOLS X-No-Destructive-Tools When "1", hide tools with destructiveHint=True
exclude_modules MCP_EXCLUDE_MODULES X-MCP-Exclude-Modules CSV of module names to exclude
include_modules MCP_INCLUDE_MODULES X-MCP-Include-Modules CSV of module names to include (mutually exclusive with exclude)
exclude_tools MCP_EXCLUDE_TOOLS X-MCP-Exclude-Tools CSV of tool names to exclude

Review & Testing Checklist for Human

  • Verify module structure is correct: The new server_config.py module extracts config classes from server.py. Verify imports work correctly and there are no circular dependencies.
  • Verify private API usage is acceptable: The _get_tool_by_name method accesses app._tool_manager._tools (private attributes). This could break if FastMCP changes internals.
  • Test with actual HTTP headers: Unit tests use mocks. Manually verify that per-request filtering works end-to-end with different HTTP headers.
  • Verify module annotation access: The mcp_module annotation is accessed via getattr(annotations, "mcp_module", None) which relies on pydantic's model_extra. Confirm this works with actual tools registered via @mcp_tool.

Suggested test plan:

  1. Create a test MCP server with include_standard_tool_filters=True
  2. Register tools with different @mcp_tool annotations (read_only, destructive, different modules)
  3. Connect clients with different headers (e.g., X-MCP-Readonly-Mode: 1, X-MCP-Exclude-Modules: cloud)
  4. Verify tool visibility changes based on headers
  5. Verify calling a filtered tool returns an appropriate error

Notes

  • This PR builds on the recently merged mcp_server helper (PR feat: add mcp_server() helper with built-in server info and credential resolution #9)
  • The mcp package is added to deptry's DEP003 ignore list since it's a transitive dependency through fastmcp used for MCP protocol types
  • Standard config args, filter functions, and constants are exported from fastmcp_extensions.tool_filters for users who want to customize or extend them
  • ToolFilterMiddleware is now in the private _middleware.py module; users should use mcp_server() with tool_filters parameter for custom filters
  • The server_config.py module now contains all config-related classes, breaking the previous circular dependency between server.py and tool_filters.py

Link to Devin run: https://app.devin.ai/sessions/3e7dd2bb599e4aa496c56fa479f05892
Requested by: Aaron ("AJ") Steers (@aaronsteers)

Add middleware-based per-request tool filtering using FastMCP's middleware hooks:
- ToolFilterMiddleware class with on_list_tools and on_call_tool hooks
- ToolFilterFn type alias for filter function signatures
- Filter function receives (Tool, FastMCP) to access request-specific config via get_mcp_config()
- Comprehensive tests for middleware functionality
- Add mcp to deptry ignore list (transitive dependency through fastmcp)

Co-Authored-By: AJ Steers <aj@airbyte.io>
@devin-ai-integration
Copy link
Contributor

Original prompt from AJ Steers
Received message in Slack channel #ask-devin-ai:

@Devin - Check the deferred mcp tool registration pattern in pyairbyte - whereby we have some programmatic tool and tool arg exclusions based on specific logic. I want to port these generically into fastmcp-extensions, and the mcp developer can simply give us function callables to dynamically show/hide tools or their args based on custom logic.
Thread URL: https://airbytehq-team.slack.com/archives/C08BHPUMEPJ/p1768854939841179

@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@github-actions
Copy link

🎉 Thanks for opening this pull request!

Your contribution is appreciated. Here are some helpful commands you can use:

Quick Commands

  • /autofix - Auto-format and fix linting issues (ruff format + ruff check --fix)
  • /lock - Update the uv.lock file with latest dependencies

Available Poe Tasks

You can run any of these tasks using the slash command: /poe <task-name>

Core Tasks

  • /poe test - Run all tests
  • /poe test-fast - Run tests with fast exit on first failure
  • /poe lint - Check code style and quality
  • /poe format - Format code with ruff
  • /poe deps - Check for unused and missing dependencies
  • /poe check - Run format check, linting, dependency check, and tests

Quick Fixes

  • /poe fix - Auto-format and fix linting issues
  • /poe clean - Clean up build artifacts and cache

Build & Install

  • /poe build - Build the package
  • /poe install - Install with development dependencies

Other Commands

  • /poe version - Show package version
  • /poe pre-commit - Run pre-commit style checks

The CI will automatically run tests when you push commits. Happy coding! 🚀

Add tool_filters parameter to mcp_server() that accepts a list of filter functions.
Each filter function is wrapped in ToolFilterMiddleware and registered with the app.
This provides a cleaner API for users to add per-request tool filtering.

Co-Authored-By: AJ Steers <aj@airbyte.io>
…rameter

- Add include_standard_tool_filters parameter to mcp_server()
- Add readonly_mode and no_destructive_tools standard filters
- Add module filtering (exclude_modules, include_modules)
- Add tool name exclusion (exclude_tools)
- Update middleware.py docstring with simplified example
- Add comprehensive tests for new functionality

Co-Authored-By: AJ Steers <aj@airbyte.io>
@devin-ai-integration
Copy link
Contributor

Done! I've updated the "Basic Usage" example in middleware.py to use the simplified include_standard_tool_filters=True approach as suggested.

The implementation now includes:

  • include_standard_tool_filters parameter on mcp_server()
  • Standard filters: readonly_mode, no_destructive_tools, exclude_modules, include_modules, exclude_tools
  • Simplified docstring example showing one-line setup

All 55 tests pass locally and CI is green.

- Create tool_filters.py with all constants, config args, and public filter functions
- Define constants for config names, env vars, HTTP headers, and annotation keys
- Move STANDARD_CONFIG_ARGS and STANDARD_TOOL_FILTERS to tool_filters.py
- Rename middleware.py to _middleware.py to hide from public API
- Update __init__.py exports to include new public symbols
- Remove ToolFilterMiddleware from public exports (implementation detail)
- Update tests to import from correct modules

Co-Authored-By: AJ Steers <aj@airbyte.io>
@devin-ai-integration
Copy link
Contributor

Done! I've addressed all the feedback:

  1. Created dedicated tool_filters.py module with public filter functions (readonly_mode_filter, no_destructive_tools_filter, module_filter, tool_exclusion_filter) and config args that can be reused individually.

  2. Defined constants for all magic strings:

    • Config names: CONFIG_READONLY_MODE, CONFIG_NO_DESTRUCTIVE_TOOLS, etc.
    • Env vars: ENV_READONLY_MODE, ENV_NO_DESTRUCTIVE_TOOLS, etc.
    • HTTP headers: HEADER_READONLY_MODE, HEADER_NO_DESTRUCTIVE_TOOLS, etc.
    • Annotation keys: ANNOTATION_READ_ONLY_HINT, ANNOTATION_DESTRUCTIVE_HINT, ANNOTATION_MCP_MODULE
  3. Renamed middleware.py to _middleware.py to hide "middleware" from the public API. The only reference to "middleware" is now in the internal module's docstring linking to FastMCP docs.

  4. Removed ToolFilterMiddleware from public exports - users should use mcp_server() with tool_filters parameter instead.

All 55 tests pass locally and lint checks are green.

Co-Authored-By: AJ Steers <aj@airbyte.io>
- Create server_config.py with MCPServerConfigArg, MCPServerConfig, get_mcp_config
- Move ToolFilterFn from _middleware.py to tool_filters.py
- Update imports in server.py, tool_filters.py, _middleware.py, __init__.py
- Update test patches to reference server_config module
- Removes circular dependency: tool_filters no longer imports from server.py

Co-Authored-By: AJ Steers <aj@airbyte.io>
@aaronsteers Aaron ("AJ") Steers (aaronsteers) marked this pull request as ready for review January 19, 2026 22:44
Copilot AI review requested due to automatic review settings January 19, 2026 22:44
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds middleware-based per-request tool filtering to FastMCP servers, allowing different MCP clients to see different tools based on HTTP headers or other request context. It extracts configuration classes into a new server_config.py module to break circular dependencies.

Changes:

  • Created server_config.py module with MCPServerConfigArg, MCPServerConfig, and get_mcp_config (extracted from server.py)
  • Added tool_filters.py with standard filtering logic, config args, and filter functions
  • Added _middleware.py with ToolFilterMiddleware implementation
  • Extended mcp_server() with tool_filters and include_standard_tool_filters parameters

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/test_server.py Updated test patches to reference new server_config.get_http_headers, added comprehensive tests for tool filtering
tests/test_middleware.py New test file for ToolFilterMiddleware functionality
tests/test_fastmcp_extensions.py Updated imports after moving annotation constants
src/fastmcp_extensions/tool_filters.py New module with standard tool filters and configuration
src/fastmcp_extensions/server_config.py New module with config classes extracted from server.py
src/fastmcp_extensions/server.py Removed config classes (moved to server_config.py), added tool filter middleware registration
src/fastmcp_extensions/_middleware.py New private module with ToolFilterMiddleware implementation
src/fastmcp_extensions/__init__.py Updated exports to reflect new module structure
pyproject.toml Added mcp to deptry's DEP003 ignore list

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@aaronsteers Aaron ("AJ") Steers (aaronsteers) merged commit 7f884cf into main Jan 19, 2026
15 checks passed
@aaronsteers Aaron ("AJ") Steers (aaronsteers) deleted the devin/1768856778-middleware-tool-filtering branch January 19, 2026 23:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants