Every currently-bindable MCP tool (any tool with
- * {@code source="mcp"} and {@code available=true} in the picker).
- * MCP servers are administrator-level capabilities; once enabled
- * globally they should not be silently hidden from an agent that
- * happens to have any other binding. To deny a specific MCP tool
- * to a specific agent, use the tool-guard deny path applied
- * upstream in {@code AgentGraphBuilder}.
+ *
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
+ * administrator-level capabilities, so an agent that bound merely
+ * a skill or a built-in tool keeps full MCP access. Once the
+ * operator ticks specific MCP rows, that is read as a deliberate
+ * per-agent scope: only the ticked MCP tools stay and the rest
+ * are not auto-joined, so a role can be limited to a fixed MCP
+ * tool set. To hide a single MCP tool from an agent that ticked
+ * no MCP row, use the tool-guard deny path applied upstream in
+ * {@code AgentGraphBuilder}.
*
*/
public Set getEffectiveToolNames(Long agentId) {
@@ -361,16 +365,22 @@ public class AgentBindingService {
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
merged.addAll(SYSTEM_LEVEL_TOOLS);
- // Enabled MCP server tools auto-join the allowlist for the same
- // reason SYSTEM_LEVEL_TOOLS does: MCP servers are an
- // administrator-enabled capability, not a per-agent opt-in. Without
- // this union, an agent with any skill or built-in tool bound would
- // silently lose every MCP tool — users hit this when they bound one
- // built-in tool, didn't tick the MCP rows, and observed "only
- // built-in tools work". Operators who need to hide a specific MCP
- // tool from a specific agent still have the tool-guard deny path
- // (AgentGraphBuilder applies withDeniedToolsFiltered before this).
- merged.addAll(getEnabledMcpToolNames());
+ // MCP tools. An agent that bound only a skill or a built-in tool
+ // and ticked no MCP row keeps full access to every enabled MCP
+ // tool: MCP servers are an administrator-enabled capability and
+ // must not silently vanish just because some unrelated binding
+ // exists. But once the operator ticks specific MCP rows, that is a
+ // deliberate per-agent scope — only those MCP tools (already merged
+ // via directTools above) stay, and the rest are not auto-joined, so
+ // a role can be limited to a fixed MCP tool set. To instead hide a
+ // single MCP tool from an agent that ticked no MCP row, use the
+ // tool-guard deny path applied upstream in AgentGraphBuilder.
+ Set enabledMcpTools = getEnabledMcpToolNames();
+ boolean agentScopedMcpExplicitly =
+ directTools != null && !Collections.disjoint(directTools, enabledMcpTools);
+ if (!agentScopedMcpExplicitly) {
+ merged.addAll(enabledMcpTools);
+ }
return merged;
}
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 70d8f7ad..73f1d5aa 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
@@ -12,6 +12,8 @@ import vip.mate.MateClawApplication;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.exception.MateClawException;
+import vip.mate.tool.model.AvailableToolDTO;
+import vip.mate.tool.service.AvailableToolService;
import java.util.List;
import java.util.Set;
@@ -41,6 +43,9 @@ class AgentBindingServiceTest {
@Autowired
private AgentBindingService bindingService;
+ @Autowired
+ private AvailableToolService availableToolService;
+
@Autowired
private JdbcTemplate jdbcTemplate;
@@ -357,6 +362,41 @@ class AgentBindingServiceTest {
+ "否则 AgentToolSet.withAllowedToolsOnly 会变成空集禁掉所有工具");
}
+ @Test
+ @DisplayName("Issue #117: agent 显式勾选某个 MCP 工具后,只有该工具进入 allowlist,其它 MCP 工具不再自动并入")
+ void mcpToolsScopedWhenAgentPicksSpecificMcpTool() {
+ // Enterprise scenario: a role should be limited to a fixed subset
+ // of MCP tools. Two enabled MCP servers exist; the operator ticks
+ // only server A's tool. Server B's tool must NOT leak into the
+ // allowlist just because its server is enabled at the system level.
+ seedMcpServerWithOneTool(8_888_101L, "issue117-server-a", "alpha_probe");
+ seedMcpServerWithOneTool(8_888_102L, "issue117-server-b", "beta_probe");
+
+ String mcpA = mcpToolNameForServer(8_888_101L);
+ String mcpB = mcpToolNameForServer(8_888_102L);
+ assertNotNull(mcpA, "server A 的 MCP 工具应出现在 picker 中");
+ assertNotNull(mcpB, "server B 的 MCP 工具应出现在 picker 中");
+
+ bindingService.setToolBindings(agentId, List.of(mcpA));
+
+ Set effective = bindingService.getEffectiveToolNames(agentId);
+ assertNotNull(effective, "binding 非空时应返回 allowlist(非 null)");
+ assertTrue(effective.contains(mcpA), "显式勾选的 MCP 工具必须在 allowlist 中");
+ assertFalse(effective.contains(mcpB),
+ "未勾选的其它 MCP 工具不得自动并入 —— 这正是 issue #117 要求的按岗位限定 MCP 范围。"
+ + "实际 allowlist: " + effective);
+ }
+
+ /** Picker name the UI would save for the (only) MCP tool of {@code serverId}. */
+ private String mcpToolNameForServer(long serverId) {
+ return availableToolService.listAvailable().stream()
+ .filter(t -> "mcp".equals(t.getSource()))
+ .filter(t -> t.getProviderId() != null && serverId == t.getProviderId())
+ .map(AvailableToolDTO::getName)
+ .findFirst()
+ .orElse(null);
+ }
+
@Test
@DisplayName("Issue #143: 绑定任意工具后,wiki 知识库工具仍留在 effective allowlist(可读写知识库)")
void wikiToolsSurviveSkillBindingAllowlist() {
diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue
index 467ff6e6..a705a639 100644
--- a/mateclaw-ui/src/views/Agents.vue
+++ b/mateclaw-ui/src/views/Agents.vue
@@ -395,18 +395,22 @@