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)
+}