mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
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:
parent
950821cb9b
commit
f394c0d8c8
@ -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 DeerFlow’s 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 DeerFlow’s agent system at runtime. Once enabled, these tools become available to agents without additional code changes.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
274
backend/tests/test_mcp_custom_interceptors.py
Normal file
274
backend/tests/test_mcp_custom_interceptors.py
Normal 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]
|
||||||
@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"mcpInterceptors": [
|
||||||
|
"my_package.mcp.auth:build_auth_interceptor"
|
||||||
|
],
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"filesystem": {
|
"filesystem": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user