mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
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:
parent
bc9ee9645c
commit
5ba25b06ec
19 changed files with 884 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,53 @@ DeerFlow supports configurable MCP servers and skills to extend its capabilities
|
|||
3. Configure each server’s 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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
110
backend/tests/test_mcp_routing_config.py
Normal file
110
backend/tests/test_mcp_routing_config.py
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
122
backend/tests/test_mcp_routing_metadata.py
Normal file
122
backend/tests/test_mcp_routing_metadata.py
Normal 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"] == ["查库"]
|
||||
136
backend/tests/test_mcp_routing_prompt.py
Normal file
136
backend/tests/test_mcp_routing_prompt.py
Normal 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
|
||||
|
|
@ -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": {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue