feat(mcp): add MCP routing hints (#4004)

* feat: add MCP routing hints

* test: isolate mcp routing prompt config

* fix: address mcp routing review feedback
This commit is contained in:
Ryker_Feng 2026-07-09 16:26:31 +08:00 committed by GitHub
parent bc9ee9645c
commit 5ba25b06ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 884 additions and 18 deletions

View file

@ -354,6 +354,7 @@ See the [Sandbox Configuration Guide](backend/docs/CONFIGURATION.md#sandbox) to
DeerFlow supports configurable MCP servers and skills to extend its capabilities.
For HTTP/SSE MCP servers, OAuth token flows are supported (`client_credentials`, `refresh_token`).
For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_timeout`.
MCP routing hints can also prefer a specific MCP tool for matching requests without changing tool binding; when `tool_search` defers MCP schemas, the hint directs the agent to fetch the tool first.
See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions.
#### IM Channels

View file

@ -422,6 +422,14 @@ Additional providers also live here (`boxlite`, `brave`, `browserless`, `crawl4a
- **Cache invalidation**: Detects config file changes via mtime comparison
- **Transports**: stdio (command-based), SSE, HTTP
- **OAuth (HTTP/SSE)**: Supports token endpoint flows (`client_credentials`, `refresh_token`) with automatic token refresh + Authorization header injection
- **Routing hints**: `extensions_config.json -> mcpServers.<server>.routing` and
`tools.<original_tool_name>.routing` are soft preference metadata. The effective
routing is resolved while `mcp/tools.py::get_mcp_tools()` still has both
`source_name` and the original MCP tool name, then stored on `tool.metadata`
under `deerflow_mcp_routing`. Prompt rendering uses
`tools/builtins/tool_search.py::get_mcp_routing_hints_prompt_section`, which
references `tool_search` when a hinted MCP tool is currently deferred; do not
add a parallel routing middleware for PR1-style preference hints.
- **Stdio file outputs**: Persistent stdio sessions are scoped by `user_id:thread_id`. For stdio transports only, DeerFlow pins the subprocess default `cwd` to the thread workspace and `TMPDIR`/`TMP`/`TEMP` to `workspace/.mcp/tmp/`, unless the operator explicitly configured `cwd` or temp env values. SSE/HTTP transports skip this filesystem prep entirely.
- **Stdio path translation**: MCP-returned local file references are not copied. If a `ResourceLink` or conservative free-text path resolves to an existing file inside the thread's mounted user-data tree, it is translated deterministically to `/mnt/user-data/...`; paths outside that tree remain unchanged.
- **Runtime updates**: Gateway API saves to extensions_config.json; the Gateway-embedded runtime detects changes via mtime
@ -707,7 +715,7 @@ Returns `{}` when Langfuse is not in the enabled providers — LangSmith-only de
- `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens, staleness_review_enabled, staleness_age_days, staleness_min_candidates, staleness_max_removals_per_cycle, staleness_protected_categories)
**`extensions_config.json`**:
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description)
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description, `routing`, `tools`, `tool_call_timeout`). `routing.mode="prefer"` emits `<mcp_routing_hints>` prompt guidance; if `tool_search` defers the hinted tool, the guidance points at promotion first. It does not hard-disable other tools.
- `skills` - Map of skill name → state (enabled)
Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` methods.

View file

@ -317,6 +317,26 @@ MCP servers and skill states in a single file:
"client_id": "$MCP_OAUTH_CLIENT_ID",
"client_secret": "$MCP_OAUTH_CLIENT_SECRET"
}
},
"postgres": {
"enabled": false,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"],
"description": "PostgreSQL database access",
"routing": {
"mode": "prefer",
"priority": 50,
"keywords": ["orders", "users", "SQL", "database", "table"]
},
"tools": {
"query": {
"routing": {
"priority": 100,
"keywords": ["query database", "orders table", "metrics"]
}
}
}
}
},
"skills": {
@ -325,6 +345,12 @@ MCP servers and skill states in a single file:
}
```
`routing` adds soft MCP preference hints to the agent prompt. It helps the
model prefer a configured MCP tool for matching requests without changing the
bound tool schemas or forbidding other tools. When `tool_search.enabled=true`
defers MCP schemas, the hint tells the model to fetch the deferred tool with
`tool_search` before preferring it.
### Environment Variables
- `DEER_FLOW_CONFIG_PATH` - Override config.yaml location

View file

@ -1,14 +1,15 @@
import json
import logging
import os
import re
from pathlib import Path
from typing import Literal
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from app.gateway.deps import require_admin_user
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
from deerflow.config.extensions_config import ExtensionsConfig, McpRoutingConfig, McpToolOverride, get_extensions_config, reload_extensions_config
from deerflow.mcp.cache import reset_mcp_tools_cache
logger = logging.getLogger(__name__)
@ -53,6 +54,10 @@ class McpServerConfigResponse(BaseModel):
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
oauth: McpOAuthConfigResponse | None = Field(default=None, description="OAuth configuration for MCP HTTP/SSE servers")
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
routing: McpRoutingConfig = Field(default_factory=McpRoutingConfig, description="Soft routing hints for tools from this MCP server")
tools: dict[str, McpToolOverride] = Field(default_factory=dict, description="Per-original-tool MCP configuration overrides")
tool_call_timeout: float | None = Field(default=None, description="Timeout in seconds for individual stdio MCP tool calls")
model_config = ConfigDict(extra="allow")
class McpConfigResponse(BaseModel):
@ -81,6 +86,55 @@ class McpCacheResetResponse(BaseModel):
_MASKED_VALUE = "***"
_SENSITIVE_EXTRA_KEY_RE = re.compile(
r"(^|_)(api_key|apikey|access_key|private_key|client_secret|secret|token|password|passwd|credential|credentials|authorization|bearer)(_|$)",
re.IGNORECASE,
)
def _normalize_config_key(key: str) -> str:
with_boundaries = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", key)
with_boundaries = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", with_boundaries)
return re.sub(r"[^a-z0-9]+", "_", with_boundaries.lower()).strip("_")
def _is_sensitive_extra_key(key: str) -> bool:
return bool(_SENSITIVE_EXTRA_KEY_RE.search(_normalize_config_key(key)))
def _mask_sensitive_extra_value(value: Any) -> Any:
if isinstance(value, dict):
return {key: _MASKED_VALUE if _is_sensitive_extra_key(str(key)) else _mask_sensitive_extra_value(nested) for key, nested in value.items()}
if isinstance(value, list):
return [_mask_sensitive_extra_value(item) for item in value]
return value
def _merge_extra_value_preserving_masked(key: str, incoming_value: Any, existing_value: Any, *, existing_present: bool) -> Any:
if incoming_value == _MASKED_VALUE and _is_sensitive_extra_key(key):
if existing_present:
return existing_value
raise HTTPException(
status_code=400,
detail=f"Cannot set extra config key '{key}' to masked value '***'; provide a real value.",
)
if isinstance(incoming_value, dict) and isinstance(existing_value, dict):
merged: dict[str, Any] = {}
for nested_key, nested_value in incoming_value.items():
nested_present = nested_key in existing_value
merged[nested_key] = _merge_extra_value_preserving_masked(
str(nested_key),
nested_value,
existing_value.get(nested_key),
existing_present=nested_present,
)
return merged
if isinstance(incoming_value, list) and isinstance(existing_value, list) and len(incoming_value) == len(existing_value):
return [_merge_extra_value_preserving_masked(key, nested_value, existing_value[index], existing_present=True) for index, nested_value in enumerate(incoming_value)]
return incoming_value
def _allowed_stdio_commands() -> set[str]:
@ -150,11 +204,13 @@ def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigRespo
"refresh_token": None,
}
)
masked_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (server.model_extra or {}).items()}
return server.model_copy(
update={
"env": masked_env,
"headers": masked_headers,
"oauth": masked_oauth,
**masked_extra,
}
)
@ -215,13 +271,28 @@ def _merge_preserving_secrets(
"refresh_token": merged_refresh_token,
}
)
return incoming.model_copy(
update={
"env": merged_env,
"headers": merged_headers,
"oauth": merged_oauth,
}
)
update = {
"env": merged_env,
"headers": merged_headers,
"oauth": merged_oauth,
}
if "routing" not in incoming.model_fields_set:
update["routing"] = existing.routing
if "tools" not in incoming.model_fields_set:
update["tools"] = existing.tools
incoming_extra = incoming.model_extra or {}
existing_extra = existing.model_extra or {}
for key, value in incoming_extra.items():
update[key] = _merge_extra_value_preserving_masked(
key,
value,
existing_extra.get(key),
existing_present=key in existing_extra,
)
for key, value in (existing.model_extra or {}).items():
if key not in (incoming.model_extra or {}):
update[key] = value
return incoming.model_copy(update=update)
@router.get(

View file

@ -17,6 +17,14 @@ Run `make config-upgrade` to merge new fields into your config.
## Configuration Sections
### Extensions
MCP servers and skill enabled states live in `extensions_config.json`, separate
from `config.yaml`. Use `mcpServers.<server>.routing` to add soft MCP tool
preference hints for requests that should prefer a specific MCP server or tool.
See [MCP Server Configuration](MCP_SERVER.md#routing-hints) for the schema,
example, and soft-vs-hard routing boundary.
### Models
Configure the LLM models available to the agent:

View file

@ -14,6 +14,53 @@ DeerFlow supports configurable MCP servers and skills to extend its capabilities
3. Configure each servers command, arguments, and environment variables as needed.
4. Restart the application to load and register MCP tools.
## Routing Hints
Use `routing` when an MCP server should be preferred for specific requests, such
as internal database questions that should use a PostgreSQL MCP tool before web
search. Routing hints are soft model guidance: they add a
`<mcp_routing_hints>` prompt section, but they do not forbid other tools. Use
agent-level allow/deny policy for hard restrictions. If `tool_search.enabled`
defers MCP tool schemas, the hint references `tool_search` so the model fetches
the deferred tool before preferring it.
```json
{
"mcpServers": {
"postgres": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"],
"routing": {
"mode": "prefer",
"priority": 50,
"keywords": ["orders", "users", "SQL", "database", "table"]
},
"tools": {
"query": {
"routing": {
"mode": "prefer",
"priority": 100,
"keywords": ["query database", "orders table", "metrics"]
}
}
}
}
}
}
```
- `routing.mode`: `off` disables hints; `prefer` emits hints.
- `routing.priority`: `0` to `100`; higher-priority hints are rendered first.
- `routing.keywords`: operator-authored terms that describe when to prefer the
MCP tool. Empty keywords are allowed but do not emit a hint line.
- `tools.<original_tool_name>.routing`: overrides only the fields explicitly
set for that tool. The key is the MCP server's original tool name, before the
`<server>_` prefix added for model binding. If the server-level
`routing.mode` is `off`, a tool override must set `mode: "prefer"`; setting
only `priority` or `keywords` still inherits `off` and emits no hint.
## Per-Tool Timeout (Stdio MCP Servers)
For `stdio` MCP servers, set `tool_call_timeout` to limit each individual MCP tool call in seconds:

View file

@ -386,7 +386,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Lazy import to avoid circular dependency
from deerflow.tools import get_available_tools
from deerflow.tools.builtins import setup_agent, update_agent
from deerflow.tools.builtins.tool_search import assemble_deferred_tools
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, get_mcp_routing_hints_prompt_section
cfg = _get_runtime_config(config)
resolved_app_config = app_config
@ -546,6 +546,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if non_interactive:
filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(filtered, deferred_names=setup.deferred_names)
if skill_setup.describe_skill_tool:
final_tools.append(skill_setup.describe_skill_tool)
return create_agent(
@ -567,6 +568,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
available_skills=available_skills,
app_config=resolved_app_config,
deferred_names=setup.deferred_names,
mcp_routing_hints_section=mcp_routing_hints_section,
user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
),

View file

@ -541,6 +541,8 @@ You: "Deploying to staging..." [proceed]
{deferred_tools_section}
{mcp_routing_hints_section}
{subagent_section}
<working_directory existed="true">
@ -902,6 +904,7 @@ def apply_prompt_template(
available_skills: set[str] | None = None,
app_config: AppConfig | None = None,
deferred_names: frozenset[str] = frozenset(),
mcp_routing_hints_section: str = "",
user_id: str | None = None,
skill_names: frozenset[str] | None = None,
) -> str:
@ -961,6 +964,7 @@ def apply_prompt_template(
self_update_section=_build_self_update_section(agent_name),
skills_section=skills_section,
deferred_tools_section=deferred_tools_section,
mcp_routing_hints_section=mcp_routing_hints_section,
subagent_section=subagent_section,
subagent_reminder=subagent_reminder,
skill_first_reminder=skill_first_reminder,

View file

@ -46,7 +46,7 @@ from deerflow.runtime.goal import DEFAULT_MAX_GOAL_CONTINUATIONS, build_goal_sta
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills.describe import build_skill_search_setup
from deerflow.skills.storage import get_or_new_user_skill_storage
from deerflow.tools.builtins.tool_search import assemble_deferred_tools
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, get_mcp_routing_hints_prompt_section
from deerflow.trace_context import DEERFLOW_TRACE_METADATA_KEY, generate_trace_id, get_current_trace_id, reset_current_trace_id, set_current_trace_id
from deerflow.tracing import build_tracing_callbacks, inject_langfuse_metadata
from deerflow.uploads.manager import (
@ -257,6 +257,7 @@ class DeerFlowClient:
tools = self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled)
final_tools, deferred_setup = assemble_deferred_tools(tools, enabled=self._app_config.tool_search.enabled)
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(tools, deferred_names=deferred_setup.deferred_names)
# Wire deferred skill discovery — mirrors agent.py so config flag works on both paths.
skills_list = get_enabled_skills_for_config(self._app_config)
@ -294,6 +295,7 @@ class DeerFlowClient:
available_skills=self._available_skills,
app_config=self._app_config,
deferred_names=deferred_setup.deferred_names,
mcp_routing_hints_section=mcp_routing_hints_section,
user_id=get_effective_user_id(),
skill_names=skill_setup.skill_names or None,
),

View file

@ -1,14 +1,53 @@
"""Unified extensions configuration for MCP servers and skills."""
import json
import logging
import os
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from deerflow.config.runtime_paths import existing_project_file
logger = logging.getLogger(__name__)
class McpRoutingConfig(BaseModel):
"""Soft routing hints for MCP tool preference."""
mode: Literal["off", "prefer"] = Field(
default="off",
description="Whether to emit prompt hints preferring this MCP tool for matching requests.",
)
priority: int = Field(
default=0,
description="Ordering key for routing hints. Higher values are rendered first.",
)
keywords: list[str] = Field(
default_factory=list,
description="Operator-authored keywords that describe when this MCP tool should be preferred.",
)
model_config = ConfigDict(extra="forbid")
@field_validator("priority")
@classmethod
def _clamp_priority(cls, value: int) -> int:
if value < 0:
logger.warning("MCP routing priority %s is below 0; clamping to 0.", value)
return 0
if value > 100:
logger.warning("MCP routing priority %s is above 100; clamping to 100.", value)
return 100
return value
class McpToolOverride(BaseModel):
"""Per-tool MCP configuration overrides."""
routing: McpRoutingConfig = Field(default_factory=McpRoutingConfig)
model_config = ConfigDict(extra="allow")
class McpOAuthConfig(BaseModel):
"""OAuth configuration for an MCP server (HTTP/SSE transports)."""
@ -45,6 +84,8 @@ class McpServerConfig(BaseModel):
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
oauth: McpOAuthConfig | None = Field(default=None, description="OAuth configuration (for sse or http type)")
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
routing: McpRoutingConfig = Field(default_factory=McpRoutingConfig, description="Soft routing hints for tools from this MCP server")
tools: dict[str, McpToolOverride] = Field(default_factory=dict, description="Per-original-tool MCP configuration overrides")
tool_call_timeout: float | None = Field(
default=None,
description="Timeout in seconds for individual stdio MCP tool calls. HTTP/SSE servers use transport-level timeouts. None means no timeout.",
@ -70,6 +111,18 @@ class McpServerConfig(BaseModel):
return data
def resolve_effective_mcp_routing(server_config: McpServerConfig | None, original_tool_name: str) -> dict[str, Any]:
"""Merge server-level routing with per-tool overrides for one MCP tool."""
if server_config is None:
return McpRoutingConfig().model_dump(mode="json")
effective = server_config.routing.model_dump(mode="json")
override = server_config.tools.get(original_tool_name)
if override is not None and "routing" in override.model_fields_set:
effective.update(override.routing.model_dump(mode="json", exclude_unset=True))
return effective
class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state."""

View file

@ -14,13 +14,14 @@ from urllib.parse import unquote, urlparse
from langchain_core.tools import BaseTool, StructuredTool
from langgraph.config import get_config
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.extensions_config import ExtensionsConfig, resolve_effective_mcp_routing
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, Paths, get_paths
from deerflow.mcp.client import build_servers_config
from deerflow.mcp.oauth import build_oauth_tool_interceptor, get_initial_oauth_headers
from deerflow.mcp.session_pool import get_session_pool
from deerflow.reflection import resolve_variable
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.tools.mcp_metadata import tag_mcp_routing, tag_mcp_tool
from deerflow.tools.sync import make_sync_tool_wrapper
from deerflow.tools.types import Runtime
@ -663,6 +664,12 @@ async def get_mcp_tools() -> list[BaseTool]:
transport = servers_config[source_name].get("transport", "stdio")
server_cfg = extensions_config.mcp_servers.get(source_name)
for tool in server_tools:
tag_mcp_tool(tool)
prefix = f"{source_name}_"
original_name = tool.name[len(prefix) :] if tool.name.startswith(prefix) else tool.name
routing = resolve_effective_mcp_routing(server_cfg, original_name)
if routing.get("mode") != "off":
tag_mcp_routing(tool, routing)
if tool.name.startswith(f"{source_name}_") and transport == "stdio":
_timeout = server_cfg.tool_call_timeout if server_cfg else None
wrapped_tools.append(_make_session_pool_tool(tool, source_name, servers_config[source_name], tool_interceptors, tool_call_timeout=_timeout))

View file

@ -559,7 +559,7 @@ class SubagentExecutor:
# Lazy import: see the TYPE_CHECKING note at the top of this module -
# importing tool_search runs tools/builtins/__init__, which would
# re-enter this package during its own initialization.
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, get_deferred_tools_prompt_section
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, get_deferred_tools_prompt_section, get_mcp_routing_hints_prompt_section
# Load skills as conversation items (Codex pattern)
skills = await self._load_skills()
@ -587,6 +587,9 @@ class SubagentExecutor:
deferred_section = get_deferred_tools_prompt_section(deferred_names=deferred_setup.deferred_names)
if deferred_section:
system_parts.append(deferred_section)
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(filtered_tools, deferred_names=deferred_setup.deferred_names)
if mcp_routing_hints_section:
system_parts.append(mcp_routing_hints_section)
messages: list[Any] = []
if system_parts:

View file

@ -18,6 +18,7 @@ import hashlib
import json
import logging
import re
from collections.abc import Iterable
from dataclasses import dataclass
from functools import cached_property
from typing import Annotated
@ -28,7 +29,7 @@ from langchain_core.tools import InjectedToolCallId, tool
from langchain_core.utils.function_calling import convert_to_openai_function
from langgraph.types import Command
from deerflow.tools.mcp_metadata import is_mcp_tool
from deerflow.tools.mcp_metadata import get_mcp_routing, is_mcp_tool
logger = logging.getLogger(__name__)
@ -219,3 +220,40 @@ def get_deferred_tools_prompt_section(*, deferred_names: frozenset[str] = frozen
return ""
names = "\n".join(sorted(deferred_names))
return f"<available-deferred-tools>\n{names}\n</available-deferred-tools>"
def _format_keyword_list(keywords: list[str]) -> str:
if len(keywords) == 1:
return keywords[0]
return f"{', '.join(keywords[:-1])}, or {keywords[-1]}"
def get_mcp_routing_hints_prompt_section(tools: Iterable[BaseTool], *, deferred_names: frozenset[str] = frozenset()) -> str:
"""Render <mcp_routing_hints> from MCP tools carrying routing metadata.
When tool_search has deferred an MCP tool, the hint must point the model at
promotion first; otherwise it may try to call a schema that is hidden from
the bound model request.
"""
hints: list[tuple[int, str, list[str]]] = []
for candidate in tools:
routing = get_mcp_routing(candidate)
if routing is None or routing.get("mode") != "prefer":
continue
keywords = routing.get("keywords") or []
if not keywords:
continue
hints.append((int(routing.get("priority", 0)), candidate.name, [str(keyword) for keyword in keywords]))
if not hints:
return ""
lines = ["<mcp_routing_hints>"]
for priority, tool_name, keywords in sorted(hints, key=lambda item: (-item[0], item[1])):
lines.append(f"When the user's request involves {_format_keyword_list(keywords)}:")
if tool_name in deferred_names:
lines.append(f" use `tool_search` to fetch `{tool_name}`, then prefer that MCP tool.")
else:
lines.append(f" prefer the `{tool_name}` tool.")
lines.append("</mcp_routing_hints>")
return "\n".join(lines)

View file

@ -13,9 +13,13 @@ module (including the tool loader) can import it without an import cycle.
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from langchain.tools import BaseTool
MCP_TOOL_METADATA_KEY = "deerflow_mcp"
MCP_TOOL_ROUTING_METADATA_KEY = "deerflow_mcp_routing"
def tag_mcp_tool(tool: BaseTool) -> BaseTool:
@ -27,3 +31,22 @@ def tag_mcp_tool(tool: BaseTool) -> BaseTool:
def is_mcp_tool(tool: BaseTool) -> bool:
"""True when ``tool`` carries the MCP-source tag written by :func:`tag_mcp_tool`."""
return (getattr(tool, "metadata", None) or {}).get(MCP_TOOL_METADATA_KEY) is True
def tag_mcp_routing(tool: BaseTool, routing: Mapping[str, Any]) -> BaseTool:
"""Attach serialized MCP routing metadata to ``tool``."""
tool.metadata = {
**(tool.metadata or {}),
MCP_TOOL_ROUTING_METADATA_KEY: dict(routing),
}
return tool
def get_mcp_routing(tool: BaseTool) -> dict[str, Any] | None:
"""Return routing metadata only for MCP tools whose routing mode is active."""
if not is_mcp_tool(tool):
return None
routing = (getattr(tool, "metadata", None) or {}).get(MCP_TOOL_ROUTING_METADATA_KEY)
if not isinstance(routing, dict) or routing.get("mode") == "off":
return None
return routing

View file

@ -7,6 +7,7 @@ preserves existing secrets when the frontend round-trips masked values.
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
@ -26,6 +27,7 @@ from app.gateway.routers.mcp import (
reset_mcp_tools_cache_endpoint,
update_mcp_configuration,
)
from deerflow.config.extensions_config import ExtensionsConfig
# ---------------------------------------------------------------------------
# _mask_server_config
@ -110,6 +112,26 @@ def test_mask_does_not_mutate_original():
assert masked.env["KEY"] == "***"
def test_mask_scrubs_sensitive_extra_fields_but_preserves_safe_extra_fields():
"""Unknown advanced fields are preserved, but secret-shaped keys are masked."""
server = McpServerConfigResponse(
cwd="/srv/mcp-workdir",
customFlag="keep-me",
api_key="real-extra-secret",
nested={"refreshToken": "refresh-secret", "safe": "visible"},
endpoints=[{"access_key": "access-secret", "name": "prod"}],
)
masked = _mask_server_config(server)
assert masked.model_extra["cwd"] == "/srv/mcp-workdir"
assert masked.model_extra["customFlag"] == "keep-me"
assert masked.model_extra["api_key"] == "***"
assert masked.model_extra["nested"] == {"refreshToken": "***", "safe": "visible"}
assert masked.model_extra["endpoints"] == [{"access_key": "***", "name": "prod"}]
assert server.model_extra["api_key"] == "real-extra-secret"
# ---------------------------------------------------------------------------
# _merge_preserving_secrets
# ---------------------------------------------------------------------------
@ -201,6 +223,41 @@ def test_merge_does_not_mutate_original():
assert merged.env["KEY"] == "secret"
def test_merge_preserves_masked_sensitive_extra_values():
"""Masked secret-shaped extra fields should round-trip to existing values."""
incoming = McpServerConfigResponse(
cwd="/srv/new-workdir",
api_key="***",
nested={"refreshToken": "***", "safe": "updated"},
endpoints=[{"access_key": "***", "name": "prod"}],
)
existing = McpServerConfigResponse(
cwd="/srv/old-workdir",
api_key="real-extra-secret",
nested={"refreshToken": "real-refresh", "safe": "old"},
endpoints=[{"access_key": "real-access", "name": "prod"}],
)
merged = _merge_preserving_secrets(incoming, existing)
assert merged.model_extra["cwd"] == "/srv/new-workdir"
assert merged.model_extra["api_key"] == "real-extra-secret"
assert merged.model_extra["nested"] == {"refreshToken": "real-refresh", "safe": "updated"}
assert merged.model_extra["endpoints"] == [{"access_key": "real-access", "name": "prod"}]
def test_merge_rejects_masked_sensitive_extra_value_for_new_key():
"""A new unknown secret field must provide a real value, not a mask."""
incoming = McpServerConfigResponse(api_key="***")
existing = McpServerConfigResponse()
with pytest.raises(HTTPException) as exc_info:
_merge_preserving_secrets(incoming, existing)
assert exc_info.value.status_code == 400
assert "api_key" in exc_info.value.detail
# ---------------------------------------------------------------------------
# Comment 2 fix: masked value for new key is rejected
# ---------------------------------------------------------------------------
@ -408,6 +465,130 @@ async def test_update_mcp_configuration_resets_tools_cache(monkeypatch, tmp_path
assert list(response.mcp_servers) == ["github"]
@pytest.mark.asyncio
async def test_update_mcp_configuration_preserves_omitted_routing_and_tools(monkeypatch, tmp_path):
"""Frontend toggles must not erase hand-authored MCP routing hints."""
config_path = tmp_path / "extensions_config.json"
config_path.write_text(
json.dumps(
{
"mcpServers": {
"postgres": {
"enabled": True,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres"],
"routing": {
"mode": "prefer",
"priority": 50,
"keywords": ["订单", "SQL"],
},
"tools": {
"query": {
"routing": {
"priority": 100,
"keywords": ["查库"],
}
}
},
}
},
"skills": {},
}
),
encoding="utf-8",
)
current_config = SimpleNamespace(skills={}, mcp_servers={})
def fake_reload_extensions_config():
return ExtensionsConfig.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
monkeypatch.setattr(mcp_router.ExtensionsConfig, "resolve_config_path", lambda: config_path)
monkeypatch.setattr(mcp_router, "get_extensions_config", lambda: current_config)
monkeypatch.setattr(mcp_router, "reload_extensions_config", fake_reload_extensions_config)
monkeypatch.setattr(mcp_router, "reset_mcp_tools_cache", lambda: None)
response = await update_mcp_configuration(
_request_with_role("admin"),
McpConfigUpdateRequest(
mcp_servers={
"postgres": McpServerConfigResponse(
enabled=False,
type="stdio",
command="npx",
args=["-y", "@modelcontextprotocol/server-postgres"],
)
}
),
)
persisted = json.loads(config_path.read_text(encoding="utf-8"))
postgres = persisted["mcpServers"]["postgres"]
assert postgres["enabled"] is False
assert postgres["routing"]["keywords"] == ["订单", "SQL"]
assert postgres["tools"]["query"]["routing"]["priority"] == 100
assert response.mcp_servers["postgres"].routing.keywords == ["订单", "SQL"]
@pytest.mark.asyncio
async def test_update_mcp_configuration_preserves_server_extra_fields(monkeypatch, tmp_path):
"""Gateway round-trips must preserve advanced server fields unknown to the API model."""
config_path = tmp_path / "extensions_config.json"
config_path.write_text(
json.dumps(
{
"mcpServers": {
"playwright": {
"enabled": True,
"type": "stdio",
"command": "npx",
"args": ["-y", "@playwright/mcp"],
"cwd": "/srv/mcp-workdir",
"customFlag": "keep-me",
"api_key": "real-extra-secret",
}
},
"skills": {},
}
),
encoding="utf-8",
)
current_config = SimpleNamespace(skills={}, mcp_servers={})
def fake_reload_extensions_config():
return ExtensionsConfig.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
monkeypatch.setattr(mcp_router.ExtensionsConfig, "resolve_config_path", lambda: config_path)
monkeypatch.setattr(mcp_router, "get_extensions_config", lambda: current_config)
monkeypatch.setattr(mcp_router, "reload_extensions_config", fake_reload_extensions_config)
monkeypatch.setattr(mcp_router, "reset_mcp_tools_cache", lambda: None)
response = await update_mcp_configuration(
_request_with_role("admin"),
McpConfigUpdateRequest(
mcp_servers={
"playwright": McpServerConfigResponse(
enabled=False,
type="stdio",
command="npx",
args=["-y", "@playwright/mcp"],
)
}
),
)
persisted = json.loads(config_path.read_text(encoding="utf-8"))
playwright = persisted["mcpServers"]["playwright"]
assert playwright["enabled"] is False
assert playwright["cwd"] == "/srv/mcp-workdir"
assert playwright["customFlag"] == "keep-me"
assert playwright["api_key"] == "real-extra-secret"
assert response.mcp_servers["playwright"].model_extra["cwd"] == "/srv/mcp-workdir"
assert response.mcp_servers["playwright"].model_extra["api_key"] == "***"
def test_validate_mcp_update_allows_default_npx_stdio_command(monkeypatch):
monkeypatch.delenv(_MCP_STDIO_COMMAND_ALLOWLIST_ENV, raising=False)
request = McpConfigUpdateRequest(

View file

@ -0,0 +1,110 @@
"""Tests for MCP routing hint configuration."""
from __future__ import annotations
import logging
import pytest
from pydantic import ValidationError
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig, resolve_effective_mcp_routing
def test_server_default_routing_applies_to_every_tool():
config = ExtensionsConfig.model_validate(
{
"mcpServers": {
"postgres": {
"routing": {
"mode": "prefer",
"priority": 50,
"keywords": ["订单", "SQL"],
}
}
}
}
)
routing = resolve_effective_mcp_routing(config.mcp_servers["postgres"], "query")
assert routing["mode"] == "prefer"
assert routing["priority"] == 50
assert routing["keywords"] == ["订单", "SQL"]
def test_tool_routing_override_only_replaces_explicit_fields():
config = ExtensionsConfig.model_validate(
{
"mcpServers": {
"postgres": {
"routing": {
"mode": "prefer",
"priority": 20,
"keywords": ["database", "table"],
},
"tools": {
"query": {
"routing": {
"priority": 100,
}
}
},
}
}
}
)
routing = resolve_effective_mcp_routing(config.mcp_servers["postgres"], "query")
assert routing == {
"mode": "prefer",
"priority": 100,
"keywords": ["database", "table"],
}
def test_invalid_routing_mode_fails_validation():
with pytest.raises(ValidationError):
ExtensionsConfig.model_validate(
{
"mcpServers": {
"postgres": {
"routing": {
"mode": "require",
}
}
}
}
)
@pytest.mark.parametrize(
("raw_priority", "expected"),
[
(-1, 0),
(101, 100),
],
)
def test_out_of_range_priority_is_clamped_with_warning(caplog, raw_priority: int, expected: int):
caplog.set_level(logging.WARNING)
server = McpServerConfig(routing={"mode": "prefer", "priority": raw_priority})
assert server.routing.priority == expected
assert "MCP routing priority" in caplog.text
def test_unknown_routing_fields_are_rejected():
with pytest.raises(ValidationError):
ExtensionsConfig.model_validate(
{
"mcpServers": {
"postgres": {
"routing": {
"mode": "prefer",
"unknown": True,
}
}
}
}
)

View file

@ -0,0 +1,122 @@
"""Tests for MCP routing metadata tags."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.tools.mcp_metadata import MCP_TOOL_METADATA_KEY, MCP_TOOL_ROUTING_METADATA_KEY, get_mcp_routing, tag_mcp_routing, tag_mcp_tool
class _Args(BaseModel):
query: str = Field(..., description="query")
def _tool(name: str = "postgres_query") -> StructuredTool:
async def _call(query: str) -> str:
return query
return StructuredTool(
name=name,
description="Query internal data",
args_schema=_Args,
coroutine=_call,
)
def test_tag_mcp_routing_preserves_existing_mcp_flag():
tool = tag_mcp_tool(_tool())
tagged = tag_mcp_routing(
tool,
{
"mode": "prefer",
"priority": 80,
"keywords": ["订单"],
},
)
assert tagged.metadata[MCP_TOOL_METADATA_KEY] is True
assert tagged.metadata[MCP_TOOL_ROUTING_METADATA_KEY]["priority"] == 80
assert get_mcp_routing(tagged)["keywords"] == ["订单"]
def test_get_mcp_routing_returns_none_for_non_mcp_tools():
tool = tag_mcp_routing(
_tool(),
{
"mode": "prefer",
"priority": 80,
"keywords": ["订单"],
},
)
assert get_mcp_routing(tool) is None
def test_get_mcp_routing_returns_none_for_off_mode():
tool = tag_mcp_tool(_tool())
tag_mcp_routing(
tool,
{
"mode": "off",
"priority": 80,
"keywords": ["订单"],
},
)
assert get_mcp_routing(tool) is None
@pytest.mark.asyncio
@pytest.mark.parametrize("transport", ["http", "stdio"])
async def test_get_mcp_tools_tags_effective_routing_metadata(transport: str):
from deerflow.mcp.tools import get_mcp_tools
tool = _tool("postgres_query")
extensions_config = ExtensionsConfig.model_validate(
{
"mcpServers": {
"postgres": {
"type": transport,
"url": "http://localhost:8000/mcp",
"command": "npx",
"routing": {
"mode": "prefer",
"priority": 50,
"keywords": ["database"],
},
"tools": {
"query": {
"routing": {
"priority": 100,
"keywords": ["查库"],
}
}
},
}
}
}
)
with (
patch("deerflow.mcp.tools.ExtensionsConfig.from_file", return_value=extensions_config),
patch(
"deerflow.mcp.tools.build_servers_config",
return_value={"postgres": {"transport": transport, "url": "http://localhost:8000/mcp", "command": "npx"}},
),
patch("deerflow.mcp.tools.get_initial_oauth_headers", return_value={}),
patch("deerflow.mcp.tools.build_oauth_tool_interceptor", return_value=None),
patch("langchain_mcp_adapters.client.MultiServerMCPClient") as MockClient,
):
MockClient.return_value.get_tools = AsyncMock(return_value=[tool])
tools = await get_mcp_tools()
routing = get_mcp_routing(tools[0])
assert routing is not None
assert routing["priority"] == 100
assert routing["keywords"] == ["查库"]

View file

@ -0,0 +1,136 @@
"""Tests for MCP routing hint prompt rendering."""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from langchain_core.tools import StructuredTool
from langchain_core.utils.function_calling import convert_to_openai_function
from pydantic import BaseModel, Field
from deerflow.agents.lead_agent.prompt import apply_prompt_template
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, get_mcp_routing_hints_prompt_section
from deerflow.tools.mcp_metadata import tag_mcp_routing, tag_mcp_tool
class _Args(BaseModel):
query: str = Field(..., description="query")
def _tool(name: str, description: str = "Query internal data") -> StructuredTool:
async def _call(query: str) -> str:
return query
return StructuredTool(
name=name,
description=description,
args_schema=_Args,
coroutine=_call,
)
def _routed_tool(name: str, *, priority: int, keywords: list[str], mode: str = "prefer") -> StructuredTool:
tool = tag_mcp_tool(_tool(name))
tag_mcp_routing(
tool,
{
"mode": mode,
"priority": priority,
"keywords": keywords,
},
)
return tool
def _minimal_prompt_app_config() -> SimpleNamespace:
return SimpleNamespace(
sandbox=SimpleNamespace(mounts=[]),
skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: Path("/tmp/skills")),
skill_evolution=SimpleNamespace(enabled=False),
acp_agents={},
)
def test_zero_mcp_routing_tools_render_empty_section():
assert get_mcp_routing_hints_prompt_section([]) == ""
def test_off_mode_and_empty_keywords_are_excluded():
section = get_mcp_routing_hints_prompt_section(
[
_routed_tool("postgres_query", priority=100, keywords=["订单"], mode="off"),
_routed_tool("metrics_query", priority=90, keywords=[]),
]
)
assert section == ""
def test_routing_hints_are_ordered_by_priority_then_name():
section = get_mcp_routing_hints_prompt_section(
[
_routed_tool("z_tool", priority=50, keywords=["z"]),
_routed_tool("a_tool", priority=50, keywords=["a"]),
_routed_tool("top_tool", priority=90, keywords=["top", "SQL"]),
]
)
assert section.startswith("<mcp_routing_hints>")
top_index = section.index("`top_tool`")
a_index = section.index("`a_tool`")
z_index = section.index("`z_tool`")
assert top_index < a_index < z_index
assert "When the user's request involves top, or SQL:" in section
assert "prefer the `top_tool` tool." in section
assert "priority" not in section
def test_deferred_routing_hints_use_tool_search_promotion():
routed = _routed_tool("postgres_query", priority=100, keywords=["订单"])
_, deferred_setup = assemble_deferred_tools([routed], enabled=True)
section = get_mcp_routing_hints_prompt_section([routed], deferred_names=deferred_setup.deferred_names)
assert "When the user's request involves 订单:" in section
assert "use `tool_search` to fetch `postgres_query`, then prefer that MCP tool." in section
assert "prefer the `postgres_query` tool." not in section
def test_apply_prompt_template_places_routing_hints_after_deferred_tools(monkeypatch):
section = get_mcp_routing_hints_prompt_section(
[
_routed_tool("postgres_query", priority=100, keywords=["订单"]),
]
)
empty_storage = SimpleNamespace(load_skills=lambda *, enabled_only: [])
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_skill_storage", lambda **kwargs: empty_storage)
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_or_new_user_skill_storage", lambda *args, **kwargs: empty_storage)
monkeypatch.setattr("deerflow.agents.lead_agent.prompt.get_agent_soul", lambda agent_name=None: "")
prompt = apply_prompt_template(
app_config=_minimal_prompt_app_config(),
deferred_names=frozenset({"postgres_query"}),
mcp_routing_hints_section=section,
)
assert "<available-deferred-tools>" in prompt
assert "<mcp_routing_hints>" in prompt
assert prompt.index("<available-deferred-tools>") < prompt.index("<mcp_routing_hints>")
def test_routing_metadata_does_not_change_openai_function_schema():
tool = tag_mcp_tool(_tool("postgres_query"))
before = convert_to_openai_function(tool)
tag_mcp_routing(
tool,
{
"mode": "prefer",
"priority": 100,
"keywords": ["订单"],
},
)
after = convert_to_openai_function(tool)
assert after == before

View file

@ -27,7 +27,31 @@
"postgresql://localhost/mydb"
],
"env": {},
"description": "PostgreSQL database access"
"description": "PostgreSQL database access",
"routing": {
"mode": "prefer",
"priority": 50,
"keywords": [
"database",
"SQL",
"table",
"订单",
"用户"
]
},
"tools": {
"query": {
"routing": {
"mode": "prefer",
"priority": 100,
"keywords": [
"查库",
"订单表",
"指标"
]
}
}
}
}
},
"skills": {}