feat(mcp): support custom tool interceptors via extensions_config.json (#2451)

* feat(mcp): support custom tool interceptors via extensions_config.json

Add a generic extension point for registering custom MCP tool
interceptors through `extensions_config.json`. This allows downstream
projects to inject per-request header manipulation, auth context
propagation, or other cross-cutting concerns without modifying
DeerFlow source code.

Interceptors are declared as Python callable paths in a new
`mcpInterceptors` array field and loaded via the existing
`resolve_variable` reflection mechanism:

```json
{
  "mcpInterceptors": [
    "my_package.mcp.auth:build_auth_interceptor"
  ]
}
```

Each entry must resolve to a no-arg builder function that returns an
async interceptor compatible with `MultiServerMCPClient`'s
`tool_interceptors` interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(mcp): add unit tests for custom tool interceptors

Cover all branches of the mcpInterceptors loading logic:

- valid interceptor loaded and appended to tool_interceptors
- multiple interceptors loaded in declaration order
- builder returning None is skipped
- resolve_variable ImportError logged and skipped
- builder raising exception logged and skipped
- absent mcpInterceptors field is safe (no-op)
- custom interceptors coexist with OAuth interceptor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(mcp): validate mcpInterceptors type and fix lint warnings

Address review feedback:

1. Validate mcpInterceptors config value before iterating:
   - Accept a single string and normalize to [string]
   - Ignore None silently
   - Log warning and skip for non-list/non-string types

2. Fix ruff F841 lint errors in tests:
   - Rename _make_mock_env to _make_patches, embed mock_client
   - Remove unused `as mock_cls` bindings where not needed
   - Extract _get_interceptors() helper to reduce repetition

3. Add two new test cases for type validation:
   - test_mcp_interceptors_single_string_is_normalized
   - test_mcp_interceptors_invalid_type_logs_warning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(mcp): validate interceptor return type and fix import mock path

Address review feedback:

1. Validate builder return type with callable() check:
   - callable interceptor → append to tool_interceptors
   - None → silently skip (builder opted out)
   - non-callable → log warning with type name and skip

2. Fix test mock path: resolve_variable is a top-level import in
   tools.py, so mock deerflow.mcp.tools.resolve_variable instead of
   deerflow.reflection.resolve_variable to correctly intercept calls.

3. Add test_custom_interceptor_non_callable_return_logs_warning to
   cover the new non-callable validation branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(mcp): add mcpInterceptors example and documentation

- Add mcpInterceptors field to extensions_config.example.json
- Add "Custom Tool Interceptors" section to MCP_SERVER.md with
  configuration format, example interceptor code, and edge case
  behavior notes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: IECspace <IECspace@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
IECspace 2026-04-25 09:18:13 +08:00 committed by GitHub
parent 950821cb9b
commit f394c0d8c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 334 additions and 0 deletions

View File

@ -45,6 +45,41 @@ Example:
} }
``` ```
## Custom Tool Interceptors
You can register custom interceptors that run before every MCP tool call. This is useful for injecting per-request headers (e.g., user auth tokens from the LangGraph execution context), logging, or metrics.
Declare interceptors in `extensions_config.json` using the `mcpInterceptors` field:
```json
{
"mcpInterceptors": [
"my_package.mcp.auth:build_auth_interceptor"
],
"mcpServers": { ... }
}
```
Each entry is a Python import path in `module:variable` format (resolved via `resolve_variable`). The variable must be a **no-arg builder function** that returns an async interceptor compatible with `MultiServerMCPClient`s `tool_interceptors` interface, or `None` to skip.
Example interceptor that injects auth headers from LangGraph metadata:
```python
def build_auth_interceptor():
async def interceptor(request, handler):
from langgraph.config import get_config
metadata = get_config().get("metadata", {})
headers = dict(request.headers or {})
if token := metadata.get("auth_token"):
headers["X-Auth-Token"] = token
return await handler(request.override(headers=headers))
return interceptor
```
- A single string value is accepted and normalized to a one-element list.
- Invalid paths or builder failures are logged as warnings without blocking other interceptors.
- The builder return value must be `callable`; non-callable values are skipped with a warning.
## How It Works ## How It Works
MCP servers expose tools that are automatically discovered and integrated into DeerFlows agent system at runtime. Once enabled, these tools become available to agents without additional code changes. MCP servers expose tools that are automatically discovered and integrated into DeerFlows agent system at runtime. Once enabled, these tools become available to agents without additional code changes.

View File

@ -12,6 +12,7 @@ from langchain_core.tools import BaseTool
from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.mcp.client import build_servers_config from deerflow.mcp.client import build_servers_config
from deerflow.mcp.oauth import build_oauth_tool_interceptor, get_initial_oauth_headers from deerflow.mcp.oauth import build_oauth_tool_interceptor, get_initial_oauth_headers
from deerflow.reflection import resolve_variable
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -95,6 +96,27 @@ async def get_mcp_tools() -> list[BaseTool]:
if oauth_interceptor is not None: if oauth_interceptor is not None:
tool_interceptors.append(oauth_interceptor) tool_interceptors.append(oauth_interceptor)
# Load custom interceptors declared in extensions_config.json
# Format: "mcpInterceptors": ["pkg.module:builder_func", ...]
raw_interceptor_paths = (extensions_config.model_extra or {}).get("mcpInterceptors")
if isinstance(raw_interceptor_paths, str):
raw_interceptor_paths = [raw_interceptor_paths]
elif not isinstance(raw_interceptor_paths, list):
if raw_interceptor_paths is not None:
logger.warning(f"mcpInterceptors must be a list of strings, got {type(raw_interceptor_paths).__name__}; skipping")
raw_interceptor_paths = []
for interceptor_path in raw_interceptor_paths:
try:
builder = resolve_variable(interceptor_path)
interceptor = builder()
if callable(interceptor):
tool_interceptors.append(interceptor)
logger.info(f"Loaded MCP interceptor: {interceptor_path}")
elif interceptor is not None:
logger.warning(f"Builder {interceptor_path} returned non-callable {type(interceptor).__name__}; skipping")
except Exception as e:
logger.warning(f"Failed to load MCP interceptor {interceptor_path}: {e}", exc_info=True)
client = MultiServerMCPClient(servers_config, tool_interceptors=tool_interceptors, tool_name_prefix=True) client = MultiServerMCPClient(servers_config, tool_interceptors=tool_interceptors, tool_name_prefix=True)
# Get all tools from all servers # Get all tools from all servers

View File

@ -0,0 +1,274 @@
"""Tests for custom MCP tool interceptors loaded via extensions_config.json."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from deerflow.mcp.tools import get_mcp_tools
def _make_patches(*, interceptor_paths=None):
"""Set up mocks for get_mcp_tools() with optional custom interceptors.
Returns a dict of patch context managers.
"""
mock_client = MagicMock()
mock_client.get_tools = AsyncMock(return_value=[])
extra = {}
if interceptor_paths is not None:
extra["mcpInterceptors"] = interceptor_paths
return {
"client_cls": patch(
"langchain_mcp_adapters.client.MultiServerMCPClient",
return_value=mock_client,
),
"from_file": patch(
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
return_value=MagicMock(
model_extra=extra,
get_enabled_mcp_servers=MagicMock(return_value={}),
),
),
"build_servers": patch(
"deerflow.mcp.tools.build_servers_config",
return_value={"test-server": {}},
),
"oauth_headers": patch(
"deerflow.mcp.tools.get_initial_oauth_headers",
new_callable=AsyncMock,
return_value={},
),
"oauth_interceptor": patch(
"deerflow.mcp.tools.build_oauth_tool_interceptor",
return_value=None,
),
}
def _get_interceptors(mock_cls):
"""Extract the tool_interceptors list passed to MultiServerMCPClient."""
kw = mock_cls.call_args
return kw.kwargs.get("tool_interceptors") or kw[1].get("tool_interceptors", [])
def test_custom_interceptor_loaded_and_appended():
"""A valid interceptor builder path is resolved, called, and appended to tool_interceptors."""
async def fake_interceptor(request, handler):
return await handler(request)
def fake_builder():
return fake_interceptor
p = _make_patches(interceptor_paths=["my_package.auth:build_interceptor"])
with (
p["client_cls"] as mock_cls,
p["from_file"],
p["build_servers"],
p["oauth_headers"],
p["oauth_interceptor"],
patch("deerflow.mcp.tools.resolve_variable", return_value=fake_builder),
):
asyncio.run(get_mcp_tools())
interceptors = _get_interceptors(mock_cls)
assert len(interceptors) == 1
assert interceptors[0] is fake_interceptor
def test_multiple_custom_interceptors():
"""Multiple interceptor paths are all loaded in order."""
async def interceptor_a(request, handler):
return await handler(request)
async def interceptor_b(request, handler):
return await handler(request)
builders = {
"pkg.a:build_a": lambda: interceptor_a,
"pkg.b:build_b": lambda: interceptor_b,
}
p = _make_patches(interceptor_paths=["pkg.a:build_a", "pkg.b:build_b"])
with (
p["client_cls"] as mock_cls,
p["from_file"],
p["build_servers"],
p["oauth_headers"],
p["oauth_interceptor"],
patch("deerflow.mcp.tools.resolve_variable", side_effect=lambda path: builders[path]),
):
asyncio.run(get_mcp_tools())
interceptors = _get_interceptors(mock_cls)
assert len(interceptors) == 2
assert interceptors[0] is interceptor_a
assert interceptors[1] is interceptor_b
def test_custom_interceptor_builder_returning_none_is_skipped():
"""If a builder returns None, it is not appended to the interceptor list."""
p = _make_patches(interceptor_paths=["pkg.noop:build_noop"])
with (
p["client_cls"] as mock_cls,
p["from_file"],
p["build_servers"],
p["oauth_headers"],
p["oauth_interceptor"],
patch("deerflow.mcp.tools.resolve_variable", return_value=lambda: None),
):
asyncio.run(get_mcp_tools())
assert len(_get_interceptors(mock_cls)) == 0
def test_custom_interceptor_resolve_error_logs_warning_and_continues():
"""A broken interceptor path logs a warning and does not block tool loading."""
p = _make_patches(interceptor_paths=["broken.path:does_not_exist"])
with (
p["client_cls"],
p["from_file"],
p["build_servers"],
p["oauth_headers"],
p["oauth_interceptor"],
patch("deerflow.mcp.tools.resolve_variable", side_effect=ImportError("no such module")),
patch("deerflow.mcp.tools.logger.warning") as mock_warn,
):
tools = asyncio.run(get_mcp_tools())
assert tools == []
mock_warn.assert_called_once()
assert "broken.path:does_not_exist" in mock_warn.call_args[0][0]
def test_custom_interceptor_builder_exception_logs_warning_and_continues():
"""If the builder function itself raises, the error is caught and logged."""
def exploding_builder():
raise RuntimeError("builder exploded")
p = _make_patches(interceptor_paths=["pkg.bad:exploding_builder"])
with (
p["client_cls"],
p["from_file"],
p["build_servers"],
p["oauth_headers"],
p["oauth_interceptor"],
patch("deerflow.mcp.tools.resolve_variable", return_value=exploding_builder),
patch("deerflow.mcp.tools.logger.warning") as mock_warn,
):
tools = asyncio.run(get_mcp_tools())
assert tools == []
mock_warn.assert_called_once()
assert "pkg.bad:exploding_builder" in mock_warn.call_args[0][0]
def test_no_mcp_interceptors_field_is_safe():
"""When mcpInterceptors is absent from config, no interceptors are added."""
p = _make_patches(interceptor_paths=None)
with (
p["client_cls"] as mock_cls,
p["from_file"],
p["build_servers"],
p["oauth_headers"],
p["oauth_interceptor"],
):
asyncio.run(get_mcp_tools())
assert len(_get_interceptors(mock_cls)) == 0
def test_custom_interceptor_coexists_with_oauth_interceptor():
"""Custom interceptors are appended after the OAuth interceptor."""
async def oauth_fn(request, handler):
return await handler(request)
async def custom_fn(request, handler):
return await handler(request)
p = _make_patches(interceptor_paths=["pkg.custom:build_custom"])
with (
p["client_cls"] as mock_cls,
p["from_file"],
p["build_servers"],
p["oauth_headers"],
patch("deerflow.mcp.tools.build_oauth_tool_interceptor", return_value=oauth_fn),
patch("deerflow.mcp.tools.resolve_variable", return_value=lambda: custom_fn),
):
asyncio.run(get_mcp_tools())
interceptors = _get_interceptors(mock_cls)
assert len(interceptors) == 2
assert interceptors[0] is oauth_fn
assert interceptors[1] is custom_fn
def test_mcp_interceptors_single_string_is_normalized():
"""A single string value for mcpInterceptors is normalized to a list."""
async def fake_interceptor(request, handler):
return await handler(request)
p = _make_patches(interceptor_paths="pkg.single:build_it")
with (
p["client_cls"] as mock_cls,
p["from_file"],
p["build_servers"],
p["oauth_headers"],
p["oauth_interceptor"],
patch("deerflow.mcp.tools.resolve_variable", return_value=lambda: fake_interceptor),
):
asyncio.run(get_mcp_tools())
assert len(_get_interceptors(mock_cls)) == 1
def test_mcp_interceptors_invalid_type_logs_warning():
"""A non-list, non-string value for mcpInterceptors logs a warning and is skipped."""
p = _make_patches(interceptor_paths=42)
with (
p["client_cls"] as mock_cls,
p["from_file"],
p["build_servers"],
p["oauth_headers"],
p["oauth_interceptor"],
patch("deerflow.mcp.tools.logger.warning") as mock_warn,
):
asyncio.run(get_mcp_tools())
assert len(_get_interceptors(mock_cls)) == 0
mock_warn.assert_called_once()
assert "must be a list" in mock_warn.call_args[0][0]
def test_custom_interceptor_non_callable_return_logs_warning():
"""If a builder returns a non-callable value, it is skipped with a warning."""
p = _make_patches(interceptor_paths=["pkg.bad:returns_string"])
with (
p["client_cls"] as mock_cls,
p["from_file"],
p["build_servers"],
p["oauth_headers"],
p["oauth_interceptor"],
patch("deerflow.mcp.tools.resolve_variable", return_value=lambda: "not_a_callable"),
patch("deerflow.mcp.tools.logger.warning") as mock_warn,
):
asyncio.run(get_mcp_tools())
assert len(_get_interceptors(mock_cls)) == 0
mock_warn.assert_called_once()
assert "non-callable" in mock_warn.call_args[0][0]

View File

@ -1,4 +1,7 @@
{ {
"mcpInterceptors": [
"my_package.mcp.auth:build_auth_interceptor"
],
"mcpServers": { "mcpServers": {
"filesystem": { "filesystem": {
"enabled": false, "enabled": false,