From 5ba25b06ecee9f4c41c5aa1270406fbae30e402d Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:26:31 +0800 Subject: [PATCH] feat(mcp): add MCP routing hints (#4004) * feat: add MCP routing hints * test: isolate mcp routing prompt config * fix: address mcp routing review feedback --- README.md | 1 + backend/AGENTS.md | 10 +- backend/README.md | 26 +++ backend/app/gateway/routers/mcp.py | 91 ++++++++- backend/docs/CONFIGURATION.md | 8 + backend/docs/MCP_SERVER.md | 47 +++++ .../deerflow/agents/lead_agent/agent.py | 4 +- .../deerflow/agents/lead_agent/prompt.py | 4 + backend/packages/harness/deerflow/client.py | 4 +- .../deerflow/config/extensions_config.py | 55 +++++- .../packages/harness/deerflow/mcp/tools.py | 9 +- .../harness/deerflow/subagents/executor.py | 5 +- .../deerflow/tools/builtins/tool_search.py | 40 +++- .../harness/deerflow/tools/mcp_metadata.py | 23 +++ backend/tests/test_mcp_config_secrets.py | 181 ++++++++++++++++++ backend/tests/test_mcp_routing_config.py | 110 +++++++++++ backend/tests/test_mcp_routing_metadata.py | 122 ++++++++++++ backend/tests/test_mcp_routing_prompt.py | 136 +++++++++++++ extensions_config.example.json | 26 ++- 19 files changed, 884 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_mcp_routing_config.py create mode 100644 backend/tests/test_mcp_routing_metadata.py create mode 100644 backend/tests/test_mcp_routing_prompt.py diff --git a/README.md b/README.md index 0fb6176c8..f7bf683ce 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/AGENTS.md b/backend/AGENTS.md index fb82c0bd6..8e6655abd 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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..routing` and + `tools..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 `` 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. diff --git a/backend/README.md b/backend/README.md index 32322cffb..85ddb0702 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/app/gateway/routers/mcp.py b/backend/app/gateway/routers/mcp.py index 59859f59f..d80622632 100644 --- a/backend/app/gateway/routers/mcp.py +++ b/backend/app/gateway/routers/mcp.py @@ -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( diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 72116fdc3..04e6ddf73 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -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..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: diff --git a/backend/docs/MCP_SERVER.md b/backend/docs/MCP_SERVER.md index c22d4b02d..3e5854cb6 100644 --- a/backend/docs/MCP_SERVER.md +++ b/backend/docs/MCP_SERVER.md @@ -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 +`` 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..routing`: overrides only the fields explicitly + set for that tool. The key is the MCP server's original tool name, before the + `_` 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: diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index edb1aa2d5..ae618299a 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -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, ), diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index e87957210..5a25c8c92 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -541,6 +541,8 @@ You: "Deploying to staging..." [proceed] {deferred_tools_section} +{mcp_routing_hints_section} + {subagent_section} @@ -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, diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index 7fd13a67e..383ad1a18 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -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, ), diff --git a/backend/packages/harness/deerflow/config/extensions_config.py b/backend/packages/harness/deerflow/config/extensions_config.py index 04c6a626e..0f6aceb55 100644 --- a/backend/packages/harness/deerflow/config/extensions_config.py +++ b/backend/packages/harness/deerflow/config/extensions_config.py @@ -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.""" diff --git a/backend/packages/harness/deerflow/mcp/tools.py b/backend/packages/harness/deerflow/mcp/tools.py index 12310a523..0a7794645 100644 --- a/backend/packages/harness/deerflow/mcp/tools.py +++ b/backend/packages/harness/deerflow/mcp/tools.py @@ -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)) diff --git a/backend/packages/harness/deerflow/subagents/executor.py b/backend/packages/harness/deerflow/subagents/executor.py index de903667b..dfb655716 100644 --- a/backend/packages/harness/deerflow/subagents/executor.py +++ b/backend/packages/harness/deerflow/subagents/executor.py @@ -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: diff --git a/backend/packages/harness/deerflow/tools/builtins/tool_search.py b/backend/packages/harness/deerflow/tools/builtins/tool_search.py index c24311510..f39e3d364 100644 --- a/backend/packages/harness/deerflow/tools/builtins/tool_search.py +++ b/backend/packages/harness/deerflow/tools/builtins/tool_search.py @@ -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"\n{names}\n" + + +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 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 = [""] + 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("") + return "\n".join(lines) diff --git a/backend/packages/harness/deerflow/tools/mcp_metadata.py b/backend/packages/harness/deerflow/tools/mcp_metadata.py index fa45b3955..e3de48b66 100644 --- a/backend/packages/harness/deerflow/tools/mcp_metadata.py +++ b/backend/packages/harness/deerflow/tools/mcp_metadata.py @@ -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 diff --git a/backend/tests/test_mcp_config_secrets.py b/backend/tests/test_mcp_config_secrets.py index 0388e1068..7f2df98a0 100644 --- a/backend/tests/test_mcp_config_secrets.py +++ b/backend/tests/test_mcp_config_secrets.py @@ -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( diff --git a/backend/tests/test_mcp_routing_config.py b/backend/tests/test_mcp_routing_config.py new file mode 100644 index 000000000..a07a09d07 --- /dev/null +++ b/backend/tests/test_mcp_routing_config.py @@ -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, + } + } + } + } + ) diff --git a/backend/tests/test_mcp_routing_metadata.py b/backend/tests/test_mcp_routing_metadata.py new file mode 100644 index 000000000..c959fc827 --- /dev/null +++ b/backend/tests/test_mcp_routing_metadata.py @@ -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"] == ["查库"] diff --git a/backend/tests/test_mcp_routing_prompt.py b/backend/tests/test_mcp_routing_prompt.py new file mode 100644 index 000000000..a0a6cf02f --- /dev/null +++ b/backend/tests/test_mcp_routing_prompt.py @@ -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("") + 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 "" in prompt + assert "" in prompt + assert prompt.index("") < prompt.index("") + + +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 diff --git a/extensions_config.example.json b/extensions_config.example.json index 38d0a81d4..bb86f1cde 100644 --- a/extensions_config.example.json +++ b/extensions_config.example.json @@ -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": {}