From 656969a471a6bba90731600e44b601246a5a7adb Mon Sep 17 00:00:00 2001
From: YUZHEthefool <2804776511@qq.com>
Date: Thu, 10 Sep 2026 23:38:42 +0800
Subject: [PATCH] feat(btw): assign MCP server tools to loops
Apply server assignments in the shared catalog and handoff predicate,
with a work-only default and explicit profile overrides in the Dashboard.
Fixes #131
AI-Generated: true
Generated-At: 2026-09-10T15:38:42Z
---
astrbot/core/config/default.py | 8 +
astrbot/core/tool_catalog.py | 8 +
.../shared/CapabilityLoopSelector.vue | 156 ++++++++++++++++++
.../components/shared/ConfigItemRenderer.vue | 8 +
.../en-US/features/config-metadata.json | 4 +
.../i18n/locales/en-US/features/config.json | 9 +
.../zh-CN/features/config-metadata.json | 4 +
.../i18n/locales/zh-CN/features/config.json | 9 +
.../tests/capabilityLoopSelector.vitest.ts | 53 ++++++
docs/en/dev/astrbot-config.md | 6 +
docs/zh/dev/astrbot-config.md | 6 +
tests/unit/test_btw_capability_routes.py | 101 ++++++++++++
12 files changed, 372 insertions(+)
create mode 100644 dashboard/src/components/shared/CapabilityLoopSelector.vue
create mode 100644 dashboard/tests/capabilityLoopSelector.vitest.ts
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index 56f2949995..7fb4da990a 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -199,6 +199,7 @@
},
"work_session": {"max_age_seconds": 3600},
"plugin_routes": [],
+ "mcp_routes": [],
},
"provider_stt_settings": {
"enable": False,
@@ -4752,6 +4753,13 @@
"_special": "select_plugin_loop_routes",
"condition": {"btw.enabled": True},
},
+ "btw.mcp_routes": {
+ "description": "MCP 服务器循环分配",
+ "type": "list",
+ "hint": "MCP 工具默认仅在工作循环可用;可按服务器显式分配给对话循环或两者。",
+ "_special": "select_mcp_loop_routes",
+ "condition": {"btw.enabled": True},
+ },
},
}
diff --git a/astrbot/core/tool_catalog.py b/astrbot/core/tool_catalog.py
index c143136ef4..9fca6e85cf 100644
--- a/astrbot/core/tool_catalog.py
+++ b/astrbot/core/tool_catalog.py
@@ -436,6 +436,14 @@ def tool_is_available_in_loop(
if not btw_config or not btw_config.get("enabled", False):
return True
raw_tool = getattr(tool, "_wrapped", tool)
+ if isinstance(raw_tool, MCPTool):
+ return route_is_available_in_loop(
+ btw_config.get("mcp_routes"),
+ route_key="server_name",
+ route_id=raw_tool.mcp_server_name,
+ loop_mode=loop_mode,
+ default_loop="work",
+ )
module_path = getattr(raw_tool, "handler_module_path", None)
plugin = plugins.get_by_module(module_path) if plugins and module_path else None
if plugin is None or getattr(plugin, "reserved", False):
diff --git a/dashboard/src/components/shared/CapabilityLoopSelector.vue b/dashboard/src/components/shared/CapabilityLoopSelector.vue
new file mode 100644
index 0000000000..09c20e69ec
--- /dev/null
+++ b/dashboard/src/components/shared/CapabilityLoopSelector.vue
@@ -0,0 +1,156 @@
+
+
+
+
+ {{ hint }}
+
+
+
+
+ | {{ tm('capabilityLoopSelector.capability') }} |
+ {{ tm('capabilityLoopSelector.loop') }} |
+
+
+
+
+ | {{ capability.label }} |
+
+
+ |
+
+
+
+
+ {{ emptyMessage }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue
index ec5d6028be..6ff378dc86 100644
--- a/dashboard/src/components/shared/ConfigItemRenderer.vue
+++ b/dashboard/src/components/shared/ConfigItemRenderer.vue
@@ -69,6 +69,13 @@
@update:model-value="emitUpdate"
/>
+
+
+
@@ -313,6 +320,7 @@ import PersonaSelector from './PersonaSelector.vue';
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue';
import PluginSetSelector from './PluginSetSelector.vue';
import PluginLoopSelector from './PluginLoopSelector.vue';
+import CapabilityLoopSelector from './CapabilityLoopSelector.vue';
import T2ITemplateEditor from './T2ITemplateEditor.vue';
import DashboardTotpManager from './DashboardTotpManager.vue';
import { computed, ref } from 'vue';
diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json
index 447116efeb..70c491a8a8 100644
--- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json
@@ -1215,6 +1215,10 @@
"plugin_routes": {
"description": "Plugin tool loop assignments",
"hint": "Plugin LLM tools default to the work loop; explicitly assign an enabled plugin to the conversation loop or both when needed."
+ },
+ "mcp_routes": {
+ "description": "MCP server loop assignments",
+ "hint": "MCP tools default to the work loop; explicitly assign an enabled server to the conversation loop or both when needed."
}
}
}
diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json
index ed9f45aeed..b1702d5e77 100644
--- a/dashboard/src/i18n/locales/en-US/features/config.json
+++ b/dashboard/src/i18n/locales/en-US/features/config.json
@@ -207,5 +207,14 @@
"work": "Work only",
"both": "Conversation and Work",
"empty": "There are no enabled non-system plugins."
+ },
+ "capabilityLoopSelector": {
+ "mcpHint": "MCP tools default to Work only. Expose a server to the conversation loop only when it is appropriate for chat-time use.",
+ "capability": "Capability",
+ "loop": "Available loop",
+ "conversation": "Conversation only",
+ "work": "Work only",
+ "both": "Conversation and Work",
+ "emptyMcp": "There are no enabled MCP servers."
}
}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
index 73ea5b3247..f8b68a3954 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
@@ -1209,6 +1209,10 @@
"plugin_routes": {
"description": "插件工具循环分配",
"hint": "插件 LLM 工具默认仅在工作循环可用;可为每个已启用插件显式改为对话循环或两者。"
+ },
+ "mcp_routes": {
+ "description": "MCP 服务器循环分配",
+ "hint": "MCP 工具默认仅在工作循环可用;可为每个已启用服务器显式改为对话循环或两者。"
}
}
}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json
index e5a5474ff6..9f6f86c63f 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/config.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/config.json
@@ -207,5 +207,14 @@
"work": "仅工作循环",
"both": "对话与工作循环",
"empty": "当前没有已启用的非系统插件。"
+ },
+ "capabilityLoopSelector": {
+ "mcpHint": "MCP 工具默认仅在工作循环可用。仅在确认服务器适合聊天调用时,才显式开放给对话循环。",
+ "capability": "能力",
+ "loop": "可用循环",
+ "conversation": "仅对话循环",
+ "work": "仅工作循环",
+ "both": "对话与工作循环",
+ "emptyMcp": "当前没有已启用的 MCP 服务器。"
}
}
diff --git a/dashboard/tests/capabilityLoopSelector.vitest.ts b/dashboard/tests/capabilityLoopSelector.vitest.ts
new file mode 100644
index 0000000000..c3c709ea8c
--- /dev/null
+++ b/dashboard/tests/capabilityLoopSelector.vitest.ts
@@ -0,0 +1,53 @@
+import { flushPromises } from '@vue/test-utils';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import CapabilityLoopSelector from '@/components/shared/CapabilityLoopSelector.vue';
+import { mountWithVuetify } from './utils/mountWithVuetify';
+
+const testState = vi.hoisted(() => ({
+ mcpListMock: vi.fn(),
+}));
+
+vi.mock('@/api/v1', () => ({
+ mcpApi: {
+ list: testState.mcpListMock,
+ },
+}));
+
+describe('CapabilityLoopSelector', () => {
+ beforeEach(() => {
+ testState.mcpListMock.mockResolvedValue({
+ data: {
+ status: 'ok',
+ data: [
+ { name: 'workspace-mcp', active: true },
+ { name: 'disabled-mcp', active: false },
+ ],
+ },
+ });
+ });
+
+ it('defaults MCP servers to work and preserves an explicit both override', async () => {
+ const wrapper = mountWithVuetify(CapabilityLoopSelector, {
+ props: {
+ kind: 'mcp',
+ modelValue: [],
+ },
+ });
+
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('workspace-mcp');
+ expect(wrapper.text()).not.toContain('disabled-mcp');
+
+ const select = wrapper.findComponent({ name: 'VSelect' });
+ expect(select.props('modelValue')).toBe('work');
+
+ select.vm.$emit('update:modelValue', 'both');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('update:modelValue')).toEqual([
+ [[{ server_name: 'workspace-mcp', loop: 'both' }]],
+ ]);
+ wrapper.unmount();
+ });
+});
diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md
index 2f78205ff9..d33ba97123 100644
--- a/docs/en/dev/astrbot-config.md
+++ b/docs/en/dev/astrbot-config.md
@@ -205,6 +205,12 @@ When BTW is enabled in a configuration profile, **Config → BTW dual loops →
The main Agent and its subagent handoffs apply the same assignment, together with existing Persona, profile, and authorization restrictions. An assignment never grants permission to execute a tool. Plugin event handlers and explicit commands keep their existing execution path; this setting does not turn an entire plugin into a background task.
+## BTW MCP tool assignments
+
+With BTW enabled, **MCP server loop assignments** selects conversation, work, or both for every enabled MCP server. All tools from that server share the assignment in the main Agent and subagent handoffs. Servers without an override default to work; selecting both saves an explicit override, and selecting work removes it. Disabling BTW preserves ordinary MCP tool availability.
+
+Assignments are saved per configuration profile. They control tool visibility and do not replace MCP read/write authorization or the existing connection, private-network, and redirect restrictions.
+
## SubAgents, speech, and knowledge base
- `subagent_orchestrator.main_enable` enables handoffs.
diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md
index eef9bedd46..79d69c7967 100644
--- a/docs/zh/dev/astrbot-config.md
+++ b/docs/zh/dev/astrbot-config.md
@@ -207,6 +207,12 @@ API Key 属于敏感配置。不要把真实 `cmd_config.json`、截图、日志
主 Agent 与其子 Agent handoff 应用相同分配,并继续遵守 Persona、配置档与授权限制。循环分配不会授予工具执行权限。插件事件处理器和显式命令保留原有执行路径;此设置不会把整个插件转换为后台任务。
+## BTW MCP 工具循环分配
+
+启用 BTW 后,可通过 **MCP 服务器循环分配** 为每个已启用服务器选择对话循环、工作循环或两者。服务器的所有工具在主 Agent 和子 Agent handoff 中遵循同一分配。没有覆盖条目的服务器默认仅工作循环可用;选择两者会保存显式覆盖,重新选择工作循环会移除覆盖。关闭 BTW 后保留普通 MCP 工具可用性。
+
+分配按配置档保存,只控制工具可见性,不替代 MCP 读写授权,也不改变现有连接、私网访问和重定向限制。
+
## 子代理、语音与知识库
- `subagent_orchestrator.main_enable`:启用 handoff。
diff --git a/tests/unit/test_btw_capability_routes.py b/tests/unit/test_btw_capability_routes.py
index fd30ad21c7..10eb4f7757 100644
--- a/tests/unit/test_btw_capability_routes.py
+++ b/tests/unit/test_btw_capability_routes.py
@@ -2,10 +2,13 @@
import json
from types import SimpleNamespace
+from unittest.mock import AsyncMock
import pytest
+from mcp.types import Tool, ToolAnnotations
from astrbot.core.agent.llm_types import ProviderRequest
+from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
@@ -126,3 +129,101 @@ def test_plugin_routes_survive_profile_save(tmp_path):
json.loads(path.read_text(encoding="utf-8-sig"))["btw"]["plugin_routes"]
== routes
)
+
+
+@pytest.mark.parametrize(
+ ("enabled", "loop", "routes", "allowed"),
+ [
+ (True, None, [], False),
+ (True, "work", [], True),
+ (False, "conversation", [], True),
+ (True, "conversation", [{"server_name": "workspace", "loop": "both"}], True),
+ (True, "work", [{"server_name": "workspace", "loop": "conversation"}], False),
+ (
+ True,
+ "conversation",
+ [{"server_name": "workspace", "loop": "invalid"}],
+ False,
+ ),
+ (True, "conversation", {"workspace": "both"}, False),
+ ],
+)
+@pytest.mark.parametrize("explicit", [False, True])
+def test_mcp_server_assignment_matches_main_and_handoff(
+ plugin_context, enabled, loop, routes, allowed, explicit
+):
+ manager = plugin_context.get_llm_tool_manager()
+ manager.func_list = [
+ MCPTool(
+ Tool(
+ name=name,
+ inputSchema={"type": "object", "properties": {}},
+ annotations=ToolAnnotations(readOnlyHint=True),
+ ),
+ AsyncMock(),
+ "workspace",
+ )
+ for name in ("list", "search")
+ ]
+ cfg = {"btw": {"enabled": enabled, "mcp_routes": routes}}
+ plugin_context.get_config = lambda **_kwargs: cfg
+ event = SimpleNamespace(
+ unified_msg_origin="webchat:FriendMessage:test",
+ get_extra=lambda key, default=None: loop if key == "btw_loop" else default,
+ plugins_name=None,
+ platform_meta=SimpleNamespace(support_proactive_message=False),
+ get_message_type=lambda: None,
+ )
+ req = ProviderRequest(prompt="hello")
+ _assemble_request_tool_catalog(
+ event,
+ req,
+ plugin_context,
+ MainAgentBuildConfig(tool_call_timeout=60, add_cron_tools=False),
+ )
+ run_context = ContextWrapper(
+ context=SimpleNamespace(event=event, context=plugin_context)
+ )
+ handoff = FunctionToolExecutor._build_handoff_toolset(
+ run_context, tools=manager.func_list if explicit else None
+ )
+ expected = {tool.name for tool in manager.func_list} if allowed else set()
+ assert req.func_tool is not None
+ assert set(req.func_tool.names()) == expected
+ assert (set(handoff.names()) if handoff is not None else set()) == expected
+
+
+def test_mcp_both_assignment_preserves_surface_authorization():
+ tool = MCPTool(
+ Tool(name="write", inputSchema={"type": "object", "properties": {}}),
+ AsyncMock(),
+ "workspace",
+ )
+ catalog = assemble_tool_catalog(
+ ToolCatalogInputs(
+ snapshot=SkillSnapshot(skills=(), runtime="none"),
+ persona_tools=None,
+ surface="im",
+ computer_use_runtime="none",
+ plugin_names=None,
+ registered_tools={tool.name: tool},
+ btw_config={
+ "enabled": True,
+ "mcp_routes": [{"server_name": "workspace", "loop": "both"}],
+ },
+ )
+ )
+ assert catalog.empty()
+
+
+def test_mcp_routes_survive_profile_save(tmp_path):
+ path = tmp_path / "profile.json"
+ routes = [{"server_name": "workspace", "loop": "both"}]
+ path.write_text(json.dumps({"btw": {"mcp_routes": routes}}), encoding="utf-8")
+ config = AstrBotConfig(
+ config_path=str(path), default_config={"btw": {"mcp_routes": []}}
+ )
+ config.save_config()
+ assert (
+ json.loads(path.read_text(encoding="utf-8-sig"))["btw"]["mcp_routes"] == routes
+ )