Every currently-bindable MCP tool ({@code source="mcp"},
* {@code available=true} in the picker) — but only when the agent
* has not ticked any MCP tool itself. MCP servers are
@@ -569,14 +570,15 @@ public class AgentBindingService implements AgentBindingResolver {
merged.addAll(directTools);
}
- // System-level tools that don't belong to any single skill but
- // are agent-wide capabilities — structured memory primitives,
- // workspace memory CRUD, etc. Without this carve-out, binding any
- // skill silently strips record_lesson / remember / *memory_file
- // tools, breaking the self-evolution loop. These survive even
- // toolsDisabled=true because they are agent-internal infrastructure,
- // unrelated to the user-facing capability picker.
- merged.addAll(SYSTEM_LEVEL_TOOLS);
+ // Binding a skill preserves the historical default capabilities, but an
+ // explicit tools opt-out must not regain file/exec/network/delegation
+ // tools through that compatibility list (issue #655).
+ merged.addAll(toolsDisabled ? MINIMAL_SYSTEM_TOOLS : SYSTEM_LEVEL_TOOLS);
+ if (toolsDisabled && !skillsDisabled) {
+ // Skill opt-out is independent. Explicitly enabled skills may still
+ // be discovered and contribute their declared tools above.
+ merged.addAll(SKILL_DISCOVERY_TOOLS);
+ }
// MCP tools. An agent that bound only a skill or a built-in tool
// and ticked no MCP row normally keeps full access to every enabled
@@ -657,13 +659,25 @@ public class AgentBindingService implements AgentBindingResolver {
}
/**
- * Tools that exist outside the skill scope and must survive any
- * agent-level skill binding restriction.
+ * Minimal internal capabilities retained by the explicit tools opt-out.
+ * General file access, execution, network access and delegation are excluded.
*
* Add new entries here only after verifying the tool is genuinely
- * agent-wide, not skill-specific. Tools added here bypass the
- * {@link #getEffectiveToolNames} allowlist completely.
+ * required for internal state rather than a user-selectable capability.
*/
+ private static final Set MINIMAL_SYSTEM_TOOLS = Set.of(
+ "record_lesson", "remember", "remember_structured", "recall_structured", "forget_structured",
+ "list_workspace_memory_files", "read_workspace_memory_file", "write_workspace_memory_file",
+ "edit_workspace_memory_file", "search_workspace_memory",
+ "getCurrentDate", "getCurrentDateTime", "getCurrentTime",
+ "setGoal", "addGoalCriterion", "completeGoal", "getGoalStatus",
+ "getManagedGoalJsonSlots", "publishManagedGoalJson", "checkManagedGoalJson",
+ "waitForGoalInput", "resumeGoal", "progress_update",
+ // These bridges resolve targets against the same effective allowlist.
+ "enable_tool", "tool_search", "tool_describe", "tool_call"
+ );
+
+ /** Compatibility defaults for skill-bound agents that have NOT disabled tools. */
private static final Set SYSTEM_LEVEL_TOOLS = Set.of(
// Structured memory primitives — used by every agent regardless
// of skill bindings, otherwise the self-evolution path collapses
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java
index c833c189..13314588 100644
--- a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java
@@ -574,12 +574,13 @@ class AgentBindingServiceTest {
assertTrue(effective.contains("record_lesson"), "system-level 必须保留");
boolean hasMcp = effective.stream().anyMatch(n -> n != null && n.startsWith("mcp_"));
assertFalse(hasMcp, "两 flag 全开时 MCP 必须完全隐藏");
- // Sanity: the set should be roughly the SYSTEM_LEVEL_TOOLS list —
- // we don't enforce equality (the constant evolves) but it should be
- // substantially smaller than the catalog of every enabled tool.
- assertTrue(effective.size() < 100,
- "两 flag 全开时返回的应该只是 system-level 内核工具,体积明显小于完整默认集。"
- + "实际大小: " + effective.size());
+ for (String name : Set.of("execute_code", "execute_shell_command", "read_file", "append_file",
+ "write_file", "edit_file", "web_search", "browser_use", "delegateToAgent", "delegateAsync",
+ "renderDocx", "image_generate", "wiki_create_page")) {
+ assertFalse(effective.contains(name), "Explicit opt-out must not restore " + name);
+ }
+ assertFalse(effective.contains("load_skill"));
+ assertFalse(effective.contains("runSkillScript"));
}
@Test
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentToolOptOutTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentToolOptOutTest.java
new file mode 100644
index 00000000..f14bb514
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentToolOptOutTest.java
@@ -0,0 +1,110 @@
+package vip.mate.agent.binding;
+
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.test.util.ReflectionTestUtils;
+import vip.mate.agent.AgentToolSet;
+import vip.mate.agent.binding.service.AgentBindingService;
+import vip.mate.agent.model.AgentEntity;
+import vip.mate.agent.repository.AgentMapper;
+import vip.mate.tool.ToolRegistry;
+import vip.mate.tool.builtin.EnableExtensionTool;
+import vip.mate.tool.builtin.ProgressiveToolBridgeTool;
+import vip.mate.tool.disclosure.ToolDisclosureService;
+import vip.mate.tool.guard.service.ToolGuardConfigService;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class AgentToolOptOutTest {
+ private static final List BUSINESS_TOOLS = List.of(
+ "execute_code", "execute_shell_command", "read_file", "append_file", "write_file", "edit_file",
+ "web_search", "browser_use", "send_file", "renderDocx", "image_generate", "music_generate",
+ "video_generate", "render_html_image", "delegateToAgent", "delegateParallel", "delegateAsync",
+ "wiki_create_page", "wiki_read_page", "mcp_test_tool");
+
+ private AgentBindingService service(boolean skillsDisabled) {
+ AgentEntity entity = new AgentEntity();
+ entity.setId(655L);
+ entity.setToolsDisabled(true);
+ entity.setSkillsDisabled(skillsDisabled);
+ AgentMapper mapper = mock(AgentMapper.class);
+ when(mapper.selectById(655L)).thenReturn(entity);
+ AgentBindingService service = mock(AgentBindingService.class, CALLS_REAL_METHODS);
+ ReflectionTestUtils.setField(service, "agentMapper", mapper);
+ if (!skillsDisabled) doReturn(Set.of(10L)).when(service).getBoundSkillIds(655L);
+ return service;
+ }
+
+ private AgentToolSet catalog() {
+ var names = new java.util.ArrayList<>(BUSINESS_TOOLS);
+ names.addAll(List.of("record_lesson", "getCurrentTime", "load_skill", "runSkillScript"));
+ List callbacks = names.stream().map(name -> {
+ ToolCallback callback = mock(ToolCallback.class);
+ when(callback.getToolDefinition()).thenReturn(ToolDefinition.builder()
+ .name(name).description(name).inputSchema("{}").build());
+ return callback;
+ }).toList();
+ return AgentToolSet.fromCallbacks(List.of(), callbacks);
+ }
+
+ @Test
+ void bothDisabledRemovesBusinessAndSkillToolsFromRuntimeCallbacks() {
+ AgentBindingService service = service(true);
+ Set allowed = service.getEffectiveToolNames(655L);
+ for (String name : BUSINESS_TOOLS) assertFalse(allowed.contains(name), name);
+ var filtered = catalog().withDeniedToolsFiltered(service.getSkillDiscoveryDeniedTools(655L))
+ .withAllowedToolsOnly(allowed);
+ assertEquals(Set.of("record_lesson", "getCurrentTime"), filtered.callbackByName().keySet());
+ assertTrue(allowed.containsAll(Set.of("getManagedGoalJsonSlots", "publishManagedGoalJson", "checkManagedGoalJson")));
+ }
+
+ @Test
+ void toolsOptOutDoesNotDisableSkillDiscoveryButDoesNotRestoreBusinessDefaults() {
+ AgentBindingService service = service(false);
+ Set allowed = service.getEffectiveToolNames(655L);
+ assertTrue(allowed.containsAll(Set.of("load_skill", "readSkillFile", "runSkillScript")));
+ for (String name : BUSINESS_TOOLS) assertFalse(allowed.contains(name), name);
+ }
+
+ @Test
+ void enabledSkillContributesOnlyItsDeclaredToolsAfterOptOut() {
+ AgentBindingService service = service(false);
+ var runtime = mock(vip.mate.skill.runtime.SkillRuntimeService.class);
+ var skill = mock(vip.mate.skill.runtime.model.ResolvedSkill.class);
+ when(skill.getId()).thenReturn(10L);
+ when(skill.isEnabled()).thenReturn(true);
+ when(skill.isRuntimeAvailable()).thenReturn(true);
+ when(skill.isDependencyReady()).thenReturn(true);
+ when(skill.getEffectiveAllowedTools()).thenReturn(Set.of("read_file"));
+ when(runtime.resolveAllSkillsStatus()).thenReturn(List.of(skill));
+ ReflectionTestUtils.setField(service, "skillRuntimeService", runtime);
+ Set allowed = service.getEffectiveToolNames(655L);
+ assertTrue(allowed.contains("read_file"));
+ assertFalse(allowed.contains("execute_code"));
+ assertFalse(allowed.contains("append_file"));
+ when(skill.isEnabled()).thenReturn(false);
+ assertFalse(service.getEffectiveToolNames(655L).contains("read_file"));
+ }
+
+ @Test
+ void dynamicBridgesCannotDiscoverOrEnableDisabledTools() {
+ AgentBindingService service = service(true);
+ ToolRegistry registry = mock(ToolRegistry.class);
+ AgentToolSet globalCatalog = catalog();
+ when(registry.getEnabledToolSet()).thenReturn(globalCatalog);
+ ToolGuardConfigService guard = mock(ToolGuardConfigService.class);
+ when(guard.getDeniedTools()).thenReturn(Set.of());
+ ToolContext context = vip.mate.agent.context.ChatOrigin.EMPTY.withAgent(655L).toToolContext();
+ var bridge = new ProgressiveToolBridgeTool(registry, service, guard);
+ String search = bridge.search(null, 20, context);
+ for (String name : BUSINESS_TOOLS) assertFalse(search.contains(name), name);
+ var enable = new EnableExtensionTool(registry, mock(ToolDisclosureService.class), service);
+ for (String name : BUSINESS_TOOLS) {
+ assertTrue(enable.enableTool(name, context).startsWith("Error:"), name);
+ }
+ }
+}
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index 61a8abaf..09b362ea 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -1826,7 +1826,7 @@ export default {
toolsTagline: 'A tool is one call, one thing. The LLM decides when to invoke each tool autonomously.',
toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.',
disableAllTools: 'This agent uses no user-pickable tools',
- disableAllToolsHint: 'Saving with this on clears the agent\'s tool bindings and marks it as "explicitly no tools": neither user-pickable tools nor any enabled MCP tools enter the allowlist. System-level primitives (structured memory, workspace memory files, delegation, etc.) remain available so the agent can still operate. When off, picking nothing still falls back to "inherit global default".',
+ disableAllToolsHint: 'Saving clears tool bindings and stops automatically providing general file access, code or shell execution, network access, delegation, document generation and MCP tools. Only internal memory, time and task-state capabilities remain. Enabled skills can still contribute their declared tools; also turn on Disable all skills to disable those. When off, picking nothing inherits global defaults.',
disableAllToolsBadge: 'Off',
searchSkills: 'Search skill name, description, or version',
searchTools: 'Search tool name, description, source, or group',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index 6cca4e45..451193ec 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -1671,7 +1671,7 @@ export default {
toolsTagline: '工具 = 一次调用,做一件事。由 LLM 自主决定何时调用。',
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
disableAllTools: '此智能体不使用任何用户可选工具',
- disableAllToolsHint: '开启后保存会清空该智能体的工具绑定,并标记为「显式无工具」:用户可选工具与已启用的 MCP 工具都不会进入 allowlist。系统级内核工具(结构化记忆、工作区记忆文件、委派等)仍保留以保证基本运行。关闭后,未勾选任何工具仍按「继承全局默认」处理。',
+ disableAllToolsHint: '开启后保存会清空工具绑定,不再自动提供通用文件读写、代码或命令执行、网络访问、委派、文档生成及 MCP 工具。仅保留记忆、时间和任务状态等内部能力。仍启用的技能可提供其声明的工具;如需一并禁用,请同时开启「禁用全部技能」。关闭后,未勾选任何工具仍按「继承全局默认」处理。',
disableAllToolsBadge: '已禁用',
searchSkills: '搜索技能名称、描述或版本',
searchTools: '搜索工具名称、描述、来源或分组',