diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/McpToolApprovalGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/McpToolApprovalGuardian.java new file mode 100644 index 000000000..69bf7a3b4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/McpToolApprovalGuardian.java @@ -0,0 +1,147 @@ +package vip.mate.tool.guard.guardian; + +import io.modelcontextprotocol.spec.McpSchema; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; +import vip.mate.tool.guard.model.*; +import vip.mate.tool.mcp.runtime.McpClientManager; +import vip.mate.tool.mcp.runtime.McpToolNameResolver; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * MCP 工具名审批守卫。 + * + *

仅匹配明确配置了 {@code tool_name} 的规则,允许配置运行时 prefixed + * 名称,或配置 MCP server 暴露的 raw tool name。 + */ +@Slf4j +@Component +public class McpToolApprovalGuardian implements ToolGuardGuardian { + + private final ToolGuardRuleRegistry ruleRegistry; + private final McpClientManager mcpClientManager; + + public McpToolApprovalGuardian(ToolGuardRuleRegistry ruleRegistry, + McpClientManager mcpClientManager) { + this.ruleRegistry = ruleRegistry; + this.mcpClientManager = mcpClientManager; + } + + @Override + public boolean supports(ToolInvocationContext context) { + return McpToolNameResolver.isMcpPrefixedName(context.toolName()); + } + + @Override + public int priority() { + return 180; + } + + @Override + public List evaluate(ToolInvocationContext context) { + String prefixedName = context.toolName(); + String rawName = resolveRawToolName(prefixedName); + List findings = new ArrayList<>(); + + for (ToolGuardRuleEntity rule : ruleRegistry.getAllEnabled()) { + String configuredToolName = normalize(rule.getToolName()); + if (configuredToolName == null) { + continue; + } + if (!matchesRule(rule, configuredToolName, prefixedName, rawName)) { + continue; + } + + findings.add(new GuardFinding( + fallback(rule.getRuleId(), "MCP_TOOL_APPROVAL"), + approvalSeverity(rule.getSeverity()), + category(rule.getCategory()), + fallback(rule.getName(), "MCP 工具调用审批"), + fallback(rule.getDescription(), "MCP 工具调用匹配审批规则,需要用户确认后执行"), + fallback(rule.getRemediation(), "请确认是否允许执行该 MCP 工具"), + prefixedName, + "tool_name", + configuredToolName, + rawName != null ? rawName : prefixedName, + Map.of( + "configuredToolName", configuredToolName, + "prefixedToolName", prefixedName, + "rawToolName", rawName != null ? rawName : "" + ) + )); + } + + return findings; + } + + private static boolean matchesRule(ToolGuardRuleEntity rule, String configuredToolName, + String prefixedName, String rawName) { + if (configuredToolName.equals(prefixedName)) { + return true; + } + if (rawName == null || !configuredToolName.equals(rawName)) { + return false; + } + return !Boolean.TRUE.equals(rule.getBuiltin()); + } + + private String resolveRawToolName(String prefixedName) { + McpToolNameResolver.ParsedRef parsed = McpToolNameResolver.parse(prefixedName); + if (parsed == null) { + return null; + } + try { + for (McpSchema.Tool tool : mcpClientManager.getServerTools(parsed.serverId())) { + String raw = tool != null ? tool.name() : null; + if (raw != null && McpToolNameResolver.hash6(raw).equals(parsed.hash6())) { + return raw; + } + } + } catch (Exception e) { + log.debug("[McpToolApprovalGuardian] Failed to resolve raw MCP tool name for {}: {}", + prefixedName, e.getMessage()); + } + return null; + } + + private static GuardSeverity approvalSeverity(String configuredSeverity) { + if (configuredSeverity == null || configuredSeverity.isBlank()) { + return GuardSeverity.MEDIUM; + } + try { + GuardSeverity severity = GuardSeverity.valueOf(configuredSeverity.trim()); + if (severity == GuardSeverity.HIGH) { + return GuardSeverity.HIGH; + } + } catch (IllegalArgumentException ignored) { + // Fall through to MEDIUM so a malformed rule still asks for approval. + } + return GuardSeverity.MEDIUM; + } + + private static GuardCategory category(String configuredCategory) { + if (configuredCategory == null || configuredCategory.isBlank()) { + return GuardCategory.CODE_EXECUTION; + } + try { + return GuardCategory.valueOf(configuredCategory.trim()); + } catch (IllegalArgumentException e) { + return GuardCategory.CODE_EXECUTION; + } + } + + private static String normalize(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private static String fallback(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/McpToolApprovalGuardianTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/McpToolApprovalGuardianTest.java new file mode 100644 index 000000000..e806d6c7f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/McpToolApprovalGuardianTest.java @@ -0,0 +1,130 @@ +package vip.mate.tool.guard.guardian; + +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; +import vip.mate.tool.guard.model.*; +import vip.mate.tool.mcp.runtime.McpClientManager; +import vip.mate.tool.mcp.runtime.McpToolNameResolver; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class McpToolApprovalGuardianTest { + + private ToolGuardRuleRegistry ruleRegistry; + private McpClientManager mcpClientManager; + private McpToolApprovalGuardian guardian; + + @BeforeEach + void setUp() { + ruleRegistry = mock(ToolGuardRuleRegistry.class); + mcpClientManager = mock(McpClientManager.class); + guardian = new McpToolApprovalGuardian(ruleRegistry, mcpClientManager); + } + + @Test + @DisplayName("prefixed MCP tool name rule creates approval finding") + void prefixedRuleMatches() { + String toolName = McpToolNameResolver.prefixedName(42L, "search_entity"); + when(ruleRegistry.getAllEnabled()).thenReturn(List.of(rule("MCP_SEARCH", toolName, "HIGH"))); + + List findings = guardian.evaluate(context(toolName)); + + assertEquals(1, findings.size()); + assertEquals("MCP_SEARCH", findings.get(0).ruleId()); + assertEquals(GuardSeverity.HIGH, findings.get(0).severity()); + assertEquals(toolName, findings.get(0).toolName()); + assertEquals(toolName, findings.get(0).matchedPattern()); + } + + @Test + @DisplayName("raw MCP tool name rule matches through server tool cache") + void rawRuleMatches() { + String rawName = "search_entity"; + String toolName = McpToolNameResolver.prefixedName(42L, rawName); + when(ruleRegistry.getAllEnabled()).thenReturn(List.of(rule("MCP_SEARCH_RAW", rawName, "MEDIUM"))); + when(mcpClientManager.getServerTools(42L)).thenReturn(List.of(tool(rawName))); + + List findings = guardian.evaluate(context(toolName)); + + assertEquals(1, findings.size()); + assertEquals("MCP_SEARCH_RAW", findings.get(0).ruleId()); + assertEquals(rawName, findings.get(0).matchedPattern()); + assertEquals(rawName, findings.get(0).snippet()); + } + + @Test + @DisplayName("blank tool_name rules are ignored for MCP approval") + void blankToolRuleIgnored() { + String toolName = McpToolNameResolver.prefixedName(42L, "search_entity"); + when(ruleRegistry.getAllEnabled()).thenReturn(List.of(rule("GLOBAL", "", "HIGH"))); + + List findings = guardian.evaluate(context(toolName)); + + assertTrue(findings.isEmpty()); + } + + @Test + @DisplayName("raw name matching does not reuse builtin non-MCP rules") + void rawRuleDoesNotMatchBuiltinRule() { + String rawName = "execute_shell_command"; + String toolName = McpToolNameResolver.prefixedName(42L, rawName); + ToolGuardRuleEntity builtinShellRule = rule("SHELL_RM", rawName, "HIGH"); + builtinShellRule.setBuiltin(true); + when(ruleRegistry.getAllEnabled()).thenReturn(List.of(builtinShellRule)); + when(mcpClientManager.getServerTools(42L)).thenReturn(List.of(tool(rawName))); + + List findings = guardian.evaluate(context(toolName)); + + assertTrue(findings.isEmpty()); + } + + @Test + @DisplayName("critical severity is coerced to approval severity") + void criticalSeverityDoesNotBlock() { + String toolName = McpToolNameResolver.prefixedName(42L, "search_entity"); + when(ruleRegistry.getAllEnabled()).thenReturn(List.of(rule("MCP_SEARCH", toolName, "CRITICAL"))); + + List findings = guardian.evaluate(context(toolName)); + GuardDecision decision = new vip.mate.tool.guard.engine.ToolPolicyResolver() + .resolve(findings, context(toolName)); + + assertEquals(1, findings.size()); + assertEquals(GuardSeverity.MEDIUM, findings.get(0).severity()); + assertEquals(GuardDecision.NEEDS_APPROVAL, decision); + } + + @Test + @DisplayName("non-MCP tools are not supported") + void nonMcpUnsupported() { + assertFalse(guardian.supports(context("execute_shell_command"))); + } + + private static ToolInvocationContext context(String toolName) { + return ToolInvocationContext.of(toolName, "{}", "conv-1", "agent-1"); + } + + private static ToolGuardRuleEntity rule(String ruleId, String toolName, String severity) { + ToolGuardRuleEntity rule = new ToolGuardRuleEntity(); + rule.setRuleId(ruleId); + rule.setName(ruleId); + rule.setDescription(ruleId); + rule.setToolName(toolName); + rule.setSeverity(severity); + rule.setCategory(GuardCategory.CODE_EXECUTION.name()); + rule.setDecision(GuardDecision.NEEDS_APPROVAL.name()); + rule.setPattern(".*"); + rule.setEnabled(true); + return rule; + } + + private static McpSchema.Tool tool(String name) { + return new McpSchema.Tool(name, null, "Test tool", null, null, null, null); + } +} diff --git a/mateclaw-ui/src/components/chat/ChatInput.vue b/mateclaw-ui/src/components/chat/ChatInput.vue index 38c20d58c..2df76b610 100644 --- a/mateclaw-ui/src/components/chat/ChatInput.vue +++ b/mateclaw-ui/src/components/chat/ChatInput.vue @@ -59,31 +59,37 @@

-
- - - - {{ t('chat.approvalAllow') }} - {{ getToolLabel(pendingApproval.toolName) }} - {{ t('chat.approvalExecute') }} +
+
+ + + + {{ t('chat.approvalAllow') }} + {{ getToolLabel(pendingApproval.toolName) }} + {{ t('chat.approvalExecute') }} +
+
+ + +
+
{{ t('chat.approvalParameters') }}
+
{{ formattedApprovalArguments }}
@@ -206,6 +212,7 @@ import { ref, computed, nextTick, watch } from 'vue' import { useI18n } from 'vue-i18n' import { CloseBold, MagicStick, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue' import { useToolLabel } from '@/composables/useToolLabel' +import { formatToolArguments, shouldShowMcpToolArguments } from '@/utils/toolArguments' import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage } from '@/types' interface Props { @@ -310,6 +317,11 @@ const inputPlaceholder = computed(() => { return props.placeholder }) +const formattedApprovalArguments = computed(() => formatToolArguments(props.pendingApproval?.arguments)) +const showApprovalArguments = computed(() => + shouldShowMcpToolArguments(props.pendingApproval?.toolName, props.pendingApproval?.arguments) +) + // 处理提交 const handleSubmit = () => { // 有排队消息时,点击按钮取消排队 @@ -681,16 +693,23 @@ defineExpose({ /* 审批栏 */ .approval-bar { display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; + flex-direction: column; + gap: 8px; background: var(--mc-input-bg, #ffffff); border-radius: 16px; - padding: 8px 8px 8px 12px; + padding: 8px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(217, 119, 87, 0.3); min-height: 50px; } +.approval-bar__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; +} + .approval-bar__info { display: flex; align-items: center; @@ -734,6 +753,34 @@ defineExpose({ flex-shrink: 0; } +.approval-bar__params { + width: 100%; + min-width: 0; + padding: 7px 9px 8px; + border-radius: 10px; + background: var(--mc-bg-sunken, #f1f5f9); + border: 1px solid var(--mc-border-light, #e5e7eb); +} + +.approval-bar__params-label { + margin-bottom: 5px; + font-size: 12px; + font-weight: 600; + color: var(--mc-text-secondary, #64748b); +} + +.approval-bar__params-code { + margin: 0; + max-height: 150px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace; + font-size: 12px; + line-height: 1.5; + color: var(--mc-text-primary, #1e293b); +} + .approval-bar__btn { display: inline-flex; align-items: center; @@ -812,6 +859,16 @@ defineExpose({ line-height: 1.55; } + .approval-bar__top { + align-items: stretch; + flex-direction: column; + gap: 8px; + } + + .approval-bar__actions { + justify-content: flex-end; + } + .action-btn { width: 32px; height: 32px; diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 0c5b4e019..cac78ccd8 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -122,16 +122,22 @@
- - - {{ $t('chat.approvalWaiting') }} {{ getToolLabel(pendingApproval.toolName) }} - - - {{ $t('chat.approved') }}: {{ getToolLabel(pendingApproval.toolName) }} - - - {{ $t('chat.denied') }}: {{ getToolLabel(pendingApproval.toolName) }} - +
+ + + {{ $t('chat.approvalWaiting') }} {{ getToolLabel(pendingApproval.toolName) }} + + + {{ $t('chat.approved') }}: {{ getToolLabel(pendingApproval.toolName) }} + + + {{ $t('chat.denied') }}: {{ getToolLabel(pendingApproval.toolName) }} + +
+
+
{{ $t('chat.approvalParameters') }}
+
{{ formattedApprovalArguments }}
+
@@ -411,6 +417,7 @@ import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttach import { useToolLabel } from '@/composables/useToolLabel' import { http } from '@/api' import { copyToClipboard } from '@/utils/clipboard' +import { formatToolArguments, shouldShowMcpToolArguments } from '@/utils/toolArguments' import TypingCursor from './TypingCursor.vue' import BrowserTimeline from './BrowserTimeline.vue' import ToolCallSegment from './ToolCallSegment.vue' @@ -1086,6 +1093,11 @@ const pendingApproval = computed(() => { return approval }) +const formattedApprovalArguments = computed(() => formatToolArguments(pendingApproval.value?.arguments)) +const showApprovalArguments = computed(() => + shouldShowMcpToolArguments(pendingApproval.value?.toolName, pendingApproval.value?.arguments) +) + const approvalSeverityClass = computed(() => { const sev = pendingApproval.value?.maxSeverity?.toLowerCase() if (!sev) return '' @@ -1545,7 +1557,8 @@ watch(isGenerating, (generating) => { /* 极简审批状态(一行式) */ .approval-inline { display: flex; - align-items: center; + flex-direction: column; + align-items: stretch; gap: 6px; padding: 8px 12px; margin-bottom: 8px; @@ -1555,6 +1568,13 @@ watch(isGenerating, (generating) => { border-radius: 8px; } +.approval-inline__main { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .approval-inline__icon { color: var(--mc-warning, #f59e0b); flex-shrink: 0; @@ -1568,6 +1588,33 @@ watch(isGenerating, (generating) => { font-weight: 500; } +.approval-inline__params { + min-width: 0; + padding: 7px 9px; + border-radius: 7px; + background: var(--mc-bg-sunken, #f1f5f9); + border: 1px solid var(--mc-border-light, #e5e7eb); +} + +.approval-inline__params-label { + margin-bottom: 5px; + font-size: 12px; + font-weight: 600; + color: var(--mc-text-secondary, #64748b); +} + +.approval-inline__params-code { + margin: 0; + max-height: 180px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace; + font-size: 12px; + line-height: 1.5; + color: var(--mc-text-primary, #1e293b); +} + .approval-inline--approved { color: var(--mc-success, #10b981); } diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 7ddcaa4b1..26f9b635c 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -350,6 +350,7 @@ export default { // Approval bar approvalAllow: 'Allow', approvalExecute: 'to execute?', + approvalParameters: 'Parameters', // Time format timeJustNow: 'Just now', timeMinutesAgo: '{n}m ago', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 106a97a49..352ef7b09 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -350,6 +350,7 @@ export default { // 审批栏 approvalAllow: '允许', approvalExecute: '执行?', + approvalParameters: '参数', // 时间格式 timeJustNow: '刚刚', timeMinutesAgo: '{n} 分钟前', diff --git a/mateclaw-ui/src/utils/toolArguments.ts b/mateclaw-ui/src/utils/toolArguments.ts new file mode 100644 index 000000000..20b19ee63 --- /dev/null +++ b/mateclaw-ui/src/utils/toolArguments.ts @@ -0,0 +1,15 @@ +export function formatToolArguments(args?: string | null): string { + if (!args) return '' + const trimmed = args.trim() + if (!trimmed) return '' + + try { + return JSON.stringify(JSON.parse(trimmed), null, 2) + } catch { + return trimmed + } +} + +export function shouldShowMcpToolArguments(toolName?: string | null, args?: string | null): boolean { + return !!toolName?.startsWith('mcp_') && !!formatToolArguments(args) +}