From 5510ea8f5aa8de7fffaddb7eb434ae3b0ac78cb0 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Sun, 10 May 2026 21:23:49 +0800 Subject: [PATCH] feat(agent): human-in-the-loop approval for MCP tool calls (#1173) Add an opt-in human approval gate so Agent runs pause before executing MCP tools that operators flag as dangerous, surface an approval card in the chat UI, and only resume after the user approves (optionally with edited args) or rejects. Backend - New mcp_tool_approvals table + repo/service to mark per-tool approval required (PG migration 000042 + sqlite init). - approval.Gate coordinates RequestAndWait / Resolve with sync.Once delivery, configurable timeout, and Redis Pub/Sub fan-out so multi- replica deployments work without sticky sessions. - MCPTool.Execute integrates the gate; uses a round-level ApprovalCtx (without the per-tool 60s timeout) for the wait, and re-derives a fresh 60s exec ctx after approval so CallTool keeps a full window. - New SSE response types (tool_approval_required / _resolved) and EventBus events plumb approval state to AgentStreamDisplay. - REST: list/set per-tool approval flag, resolve pending approval. - Configurable via agent.tool_approval_timeout_seconds (yaml) or WEKNORA_AGENT_TOOL_APPROVAL_TIMEOUT env (accepts seconds or Go duration). Frontend - MCP settings: per-tool "require approval" switch on the test panel. - Chat: ToolApprovalCard renders the pause point with editable JSON args, validation feedback, mm:ss countdown that turns warning/danger near deadline, and a resolved state that retains context. - i18n strings added for zh-CN / en-US / ko-KR / ru-RU. Docs - docs/zh/mcp-approval.md covering behavior, config, API, deployment considerations (Redis cross-instance, restart limitations). --- .env.example | 6 + docker-compose.yml | 1 + docs/zh/mcp-approval.md | 42 ++ frontend/src/api/mcp-service.ts | 28 ++ frontend/src/i18n/locales/en-US.ts | 22 +- frontend/src/i18n/locales/ko-KR.ts | 19 + frontend/src/i18n/locales/ru-RU.ts | 21 +- frontend/src/i18n/locales/zh-CN.ts | 19 + .../chat/components/AgentStreamDisplay.vue | 36 ++ .../chat/components/ToolApprovalCard.vue | 430 ++++++++++++++++++ frontend/src/views/chat/index.vue | 34 +- frontend/src/views/settings/McpSettings.vue | 3 + .../settings/components/McpTestResult.vue | 105 ++++- internal/agent/act.go | 28 +- internal/agent/approval/gate.go | 361 +++++++++++++++ internal/agent/approval/gate_test.go | 80 ++++ internal/agent/engine.go | 6 +- internal/agent/tools/exec_context.go | 40 ++ internal/agent/tools/mcp_tool.go | 74 ++- .../mcp_tool_approval_repository.go | 79 ++++ internal/application/service/agent_service.go | 6 +- .../service/mcp_tool_approval_service.go | 59 +++ internal/config/config.go | 16 +- internal/container/container.go | 8 + internal/event/event.go | 4 + internal/event/event_data.go | 28 ++ internal/handler/mcp_service.go | 130 +++++- .../handler/session/agent_stream_handler.go | 57 +++ internal/router/router.go | 8 + internal/types/chat.go | 10 +- .../types/interfaces/mcp_tool_approval.go | 21 + internal/types/mcp.go | 28 +- migrations/sqlite/000000_init.down.sql | 1 + migrations/sqlite/000000_init.up.sql | 14 + .../000042_mcp_tool_approval.down.sql | 7 + .../versioned/000042_mcp_tool_approval.up.sql | 19 + 36 files changed, 1814 insertions(+), 36 deletions(-) create mode 100644 docs/zh/mcp-approval.md create mode 100644 frontend/src/views/chat/components/ToolApprovalCard.vue create mode 100644 internal/agent/approval/gate.go create mode 100644 internal/agent/approval/gate_test.go create mode 100644 internal/agent/tools/exec_context.go create mode 100644 internal/application/repository/mcp_tool_approval_repository.go create mode 100644 internal/application/service/mcp_tool_approval_service.go create mode 100644 internal/types/interfaces/mcp_tool_approval.go create mode 100644 migrations/versioned/000042_mcp_tool_approval.down.sql create mode 100644 migrations/versioned/000042_mcp_tool_approval.up.sql diff --git a/.env.example b/.env.example index 739a638a..70b3a70c 100644 --- a/.env.example +++ b/.env.example @@ -381,6 +381,12 @@ WEKNORA_SANDBOX_DOCKER_IMAGE=wechatopenai/weknora-sandbox:latest # 注:此值为全局默认值。若单个智能体在数据库中配置了独立的 llm_call_timeout,则以智能体配置为准(优先级更高)。 # WEKNORA_AGENT_LLM_TIMEOUT=300 +# MCP 工具人工审核等待超时(秒)(可选) +# 当某个 MCP 工具被标记为「需人工审核」后,Agent 会暂停并等待用户确认; +# 该值控制最长等待时间,超时视为拒绝。默认 600(10 分钟)。 +# 也支持 Go duration 写法(如 30s / 5m / 1h)。 +# WEKNORA_AGENT_TOOL_APPROVAL_TIMEOUT=600 + # APK 镜像源设置(可选) APK_MIRROR_ARG=mirrors.tencent.com diff --git a/docker-compose.yml b/docker-compose.yml index c5e29a3c..071d6dc5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -150,6 +150,7 @@ services: - WEKNORA_SANDBOX_DOCKER_IMAGE=${WEKNORA_SANDBOX_DOCKER_IMAGE:-wechatopenai/weknora-sandbox:${WEKNORA_VERSION:-latest}} # Agent LLM call timeout - WEKNORA_AGENT_LLM_TIMEOUT=${WEKNORA_AGENT_LLM_TIMEOUT:-} + - WEKNORA_AGENT_TOOL_APPROVAL_TIMEOUT=${WEKNORA_AGENT_TOOL_APPROVAL_TIMEOUT:-} - APK_MIRROR_ARG=${APK_MIRROR_ARG:-} depends_on: redis: diff --git a/docs/zh/mcp-approval.md b/docs/zh/mcp-approval.md new file mode 100644 index 00000000..3dac8379 --- /dev/null +++ b/docs/zh/mcp-approval.md @@ -0,0 +1,42 @@ +# MCP 工具人工审核(危险调用) + +对应需求:智能体调用 MCP 工具前可中断,待人工确认后再执行(GitHub #1173)。 + +## 行为说明 + +1. 在 **设置 → MCP** 中连接测试成功后,在工具列表上打开 **「需人工审核」** 开关,即可为该工具打标。 +2. Agent 运行时若即将调用已打标的工具,会推送 `tool_approval_required` 事件,对话界面展示审批卡片(可编辑 JSON 参数)。 +3. 用户 **通过** 或 **拒绝** 后,后端恢复执行;拒绝时工具返回错误信息给模型,不会调用远端 MCP。 +4. 若超时未处理(默认 10 分钟,可通过配置 `agent.tool_approval_timeout_seconds` 调整),视为拒绝。 + +## 配置示例 + +任选一种: + +**1. config.yaml** + +```yaml +agent: + tool_approval_timeout_seconds: 600 # 可选,默认 600(秒) +``` + +**2. 环境变量**(优先级高于 yaml) + +```bash +# 支持纯秒数或 Go duration(30s / 5m / 1h) +WEKNORA_AGENT_TOOL_APPROVAL_TIMEOUT=600 +``` + +## API + +- `GET /api/v1/mcp-services/:id/tool-approvals` — 列出已保存的审核配置 +- `PUT /api/v1/mcp-services/:id/tool-approvals/:tool_name` — 设置某工具是否需审核(`{"require_approval": true}`) +- `POST /api/v1/agent/tool-approvals/:pending_id` — 在审批卡片中提交结果 + - body: `{"decision":"approve"|"reject","modified_args":{...}可选,"reason":"..."可选}` + +## 部署与限制 + +- **审批等待状态保存在进程内存** 中:`pending_id` 仅对当前实例有效;进程重启后进行中的等待会失败(表现为拒绝/取消)。 +- **多副本部署**:当配置了 `REDIS_ADDR` 时,`Resolve` 会通过 Redis Pub/Sub(频道 `weknora:mcp_approval:resolve`)跨实例转发,因此 SSE 与提交审批的 HTTP 请求落到不同实例也能正确唤醒等待者;未配置 Redis 时退化为单机模式,需要使用会话粘滞(sticky session)。 +- **审批等待不会被工具默认 60s 超时取消**:审批阶段使用 round 级别的 ctx(不带 `defaultToolExecTimeout`),仅受 `agent.tool_approval_timeout_seconds` 与请求级取消控制。 +- 安全边界:审核通过后的参数仍由当前登录租户提交;请仅在可信环境下授予「通过」权限。 diff --git a/frontend/src/api/mcp-service.ts b/frontend/src/api/mcp-service.ts index 0cb9538e..1aa7a2ee 100644 --- a/frontend/src/api/mcp-service.ts +++ b/frontend/src/api/mcp-service.ts @@ -33,6 +33,15 @@ export interface MCPTool { name: string description: string inputSchema: Record + require_approval?: boolean +} + +export interface MCPToolApprovalRow { + id: string + tenant_id?: number + service_id: string + tool_name: string + require_approval: boolean } export interface MCPResource { @@ -102,3 +111,22 @@ export async function getMCPServiceResources(id: string): Promise return response.data || [] } +/** Persisted per-tool human-approval flags (issue #1173) */ +export async function getMCPToolApprovals(serviceId: string): Promise { + const response: any = await get(`/api/v1/mcp-services/${serviceId}/tool-approvals`) + return response.data || [] +} + +export async function setMCPToolApproval(serviceId: string, toolName: string, requireApproval: boolean): Promise { + await put(`/api/v1/mcp-services/${serviceId}/tool-approvals/${encodeURIComponent(toolName)}`, { + require_approval: requireApproval + }) +} + +export async function resolveToolApproval( + pendingId: string, + body: { decision: 'approve' | 'reject'; modified_args?: Record; reason?: string } +): Promise { + await post(`/api/v1/agent/tool-approvals/${encodeURIComponent(pendingId)}`, body) +} + diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index ed45e1e3..1b906d78 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -2411,7 +2411,11 @@ export default { resourcesTitle: 'Available resources', descriptionLabel: 'Description', schemaLabel: 'Parameter schema', - emptyDescription: 'This service did not provide tools or resources' + emptyDescription: 'This service did not provide tools or resources', + requireApproval: 'Require human approval', + requireApprovalTip: + 'When enabled, the agent pauses before calling this tool until you approve — use for DB writes, deletes, etc.', + approvalSaveFailed: 'Failed to save approval setting' } }, error: { @@ -3646,6 +3650,22 @@ export default { supportedFormats: 'Supported formats', }, agentStream: { + toolApproval: { + banner: 'This MCP tool requires human approval. Review parameters before execution.', + service: 'Service', + tool: 'Tool', + argsLabel: 'Arguments', + argsModified: 'Modified', + countdown: 'About {seconds}s remaining', + approve: 'Approve & run', + reject: 'Reject', + approvedTag: 'Approved', + rejectedTag: 'Rejected', + invalidJson: 'Arguments must be valid JSON', + submitted: 'Submitted', + submitFailed: 'Submit failed', + userRejected: 'User rejected', + }, tools: { searchKnowledge: 'Knowledge Search', grepChunks: 'Text Pattern Search', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 7996294d..a9fba5dd 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -1671,6 +1671,9 @@ export default { descriptionLabel: "설명", schemaLabel: "파라미터 구조", emptyDescription: "이 서비스에서 제공하는 도구 또는 리소스가 없습니다", + requireApproval: "수동 승인 필요", + requireApprovalTip: "활성화 시 에이전트가 이 도구를 호출하기 전에 승인을 기다립니다.", + approvalSaveFailed: "승인 설정 저장 실패", }, }, error: { @@ -3714,6 +3717,22 @@ export default { supportedFormats: '지원 형식', }, agentStream: { + toolApproval: { + banner: '이 MCP 도구는 수동 승인이 필요합니다. 실행 전 매개변수를 확인하세요.', + service: '서비스', + tool: '도구', + argsLabel: '인수', + argsModified: '수정됨', + countdown: '약 {seconds}초 남음', + approve: '승인 후 실행', + reject: '거부', + approvedTag: '승인됨', + rejectedTag: '거부됨', + invalidJson: '유효한 JSON이 아닙니다', + submitted: '제출됨', + submitFailed: '제출 실패', + userRejected: '사용자 거부', + }, tools: { searchKnowledge: '지식베이스 검색', grepChunks: '텍스트 패턴 검색', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index f2721b27..db346926 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -1487,7 +1487,10 @@ export default { resourcesTitle: 'Доступные ресурсы', descriptionLabel: 'Описание', schemaLabel: 'Структура параметров', - emptyDescription: 'Сервис не предоставил инструменты или ресурсы' + emptyDescription: 'Сервис не предоставил инструменты или ресурсы', + requireApproval: 'Требуется подтверждение', + requireApprovalTip: 'При включении агент ждёт подтверждения перед вызовом инструмента.', + approvalSaveFailed: 'Не удалось сохранить настройку' } }, error: { @@ -3323,6 +3326,22 @@ export default { supportedFormats: 'Поддерживаемые форматы' }, agentStream: { + toolApproval: { + banner: 'Этот инструмент MCP требует подтверждения. Проверьте параметры.', + service: 'Сервис', + tool: 'Инструмент', + argsLabel: 'Аргументы', + argsModified: 'Изменено', + countdown: 'Осталось около {seconds} с', + approve: 'Подтвердить и выполнить', + reject: 'Отклонить', + approvedTag: 'Подтверждено', + rejectedTag: 'Отклонено', + invalidJson: 'Некорректный JSON', + submitted: 'Отправлено', + submitFailed: 'Ошибка отправки', + userRejected: 'Отклонено пользователем', + }, tools: { searchKnowledge: 'Поиск по базе знаний', grepChunks: 'Поиск по текстовому шаблону', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 2f0a0673..e850f10f 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -1651,6 +1651,9 @@ export default { descriptionLabel: "描述", schemaLabel: "参数结构", emptyDescription: "该服务未提供工具或资源", + requireApproval: "需人工审核", + requireApprovalTip: "开启后,Agent 调用该工具前会暂停并等待确认,适用于可能改库/删文件等高危操作", + approvalSaveFailed: "保存审核设置失败", }, }, error: { @@ -3643,6 +3646,22 @@ export default { supportedFormats: "支持格式", }, agentStream: { + toolApproval: { + banner: "该 MCP 工具已标记为「需人工审核」,确认参数后再执行", + service: "服务", + tool: "工具", + argsLabel: "调用参数", + argsModified: "已修改", + countdown: "剩余约 {seconds} 秒", + approve: "通过并执行", + reject: "拒绝", + approvedTag: "已通过", + rejectedTag: "已拒绝", + invalidJson: "参数不是合法 JSON", + submitted: "已提交", + submitFailed: "提交失败", + userRejected: "用户拒绝", + }, tools: { searchKnowledge: "知识库检索", grepChunks: "文本模式搜索", diff --git a/frontend/src/views/chat/components/AgentStreamDisplay.vue b/frontend/src/views/chat/components/AgentStreamDisplay.vue index 2573f756..90210388 100644 --- a/frontend/src/views/chat/components/AgentStreamDisplay.vue +++ b/frontend/src/views/chat/components/AgentStreamDisplay.vue @@ -70,6 +70,22 @@ + +
+ +
+
+ +
+ +
+
@@ -375,6 +407,7 @@ import markedKatex from 'marked-katex-extension'; import 'katex/dist/katex.min.css'; import DOMPurify from 'dompurify'; import ToolResultRenderer from './ToolResultRenderer.vue'; +import ToolApprovalCard from './ToolApprovalCard.vue'; import picturePreview from '@/components/picture-preview.vue'; import { getChunkByIdOnly } from '@/api/knowledge-base'; import { getWikiPage, type WikiPage } from '@/api/wiki'; @@ -1196,6 +1229,9 @@ const getEventKey = (event: any, index: number): string => { if (!event) return `event-${index}`; if (event.event_id) return `event-${event.event_id}`; if (event.tool_call_id) return `tool-${event.tool_call_id}`; + if (event.type === 'tool_approval_required' && event.pending_id) { + return `approval-${event.pending_id}`; + } return `event-${index}-${event.type || 'unknown'}`; }; diff --git a/frontend/src/views/chat/components/ToolApprovalCard.vue b/frontend/src/views/chat/components/ToolApprovalCard.vue new file mode 100644 index 00000000..57ec1c3c --- /dev/null +++ b/frontend/src/views/chat/components/ToolApprovalCard.vue @@ -0,0 +1,430 @@ + + + + + diff --git a/frontend/src/views/chat/index.vue b/frontend/src/views/chat/index.vue index 642b55c9..7858cd35 100644 --- a/frontend/src/views/chat/index.vue +++ b/frontend/src/views/chat/index.vue @@ -825,7 +825,7 @@ const handleAgentChunk = (data) => { // 确保在继续流式传输时(刷新页面场景),一旦接收到实际内容就关闭 loading // 这是一个保护措施,防止任何边缘情况导致 loading 残留 - if (loading.value && (data.response_type === 'thinking' || data.response_type === 'answer' || data.response_type === 'tool_call')) { + if (loading.value && (data.response_type === 'thinking' || data.response_type === 'answer' || data.response_type === 'tool_call' || data.response_type === 'tool_approval_required')) { console.log('[Agent Chunk] Closing loading for continued stream'); loading.value = false; } @@ -891,6 +891,38 @@ const handleAgentChunk = (data) => { } break; + case 'tool_approval_required': { + if (!message.agentEventStream) message.agentEventStream = []; + const d = data.data || {}; + message.agentEventStream.push({ + type: 'tool_approval_required', + pending_id: d.pending_id, + service_name: d.service_name, + mcp_tool_name: d.mcp_tool_name, + description: d.description, + args_json: d.args_json, + timeout_seconds: d.timeout_seconds, + requested_at: d.requested_at, + tool_call_id: d.tool_call_id, + resolved: false, + }); + break; + } + case 'tool_approval_resolved': { + const d = data.data || {}; + const pid = d.pending_id; + const ev = message.agentEventStream?.find( + (e) => e.type === 'tool_approval_required' && e.pending_id === pid + ); + if (ev) { + ev.resolved = true; + ev.approved = d.approved; + ev.resolve_reason = d.reason; + ev.timed_out = d.timed_out; + ev.canceled = d.canceled; + } + break; + } case 'tool_call': // Skip final_answer tool call from event stream - its content appears as answer events if (data.data && data.data.tool_name === 'final_answer') { diff --git a/frontend/src/views/settings/McpSettings.vue b/frontend/src/views/settings/McpSettings.vue index c5decc1d..07cdf10f 100644 --- a/frontend/src/views/settings/McpSettings.vue +++ b/frontend/src/views/settings/McpSettings.vue @@ -97,6 +97,7 @@ v-model:visible="testDialogVisible" :result="testResult" :service-name="testingServiceName" + :service-id="testingServiceId" />
@@ -129,6 +130,7 @@ const currentService = ref(null) const testDialogVisible = ref(false) const testResult = ref(null) const testingServiceName = ref('') +const testingServiceId = ref('') const testing = ref(false) // Load MCP services @@ -184,6 +186,7 @@ const handleTest = async (service: MCPService) => { if (!service || !service.id) return testingServiceName.value = service.name + testingServiceId.value = service.id testing.value = true MessagePlugin.info({ diff --git a/frontend/src/views/settings/components/McpTestResult.vue b/frontend/src/views/settings/components/McpTestResult.vue index 3b4b66a4..4e9e6bdb 100644 --- a/frontend/src/views/settings/components/McpTestResult.vue +++ b/frontend/src/views/settings/components/McpTestResult.vue @@ -29,7 +29,7 @@
- +
+ +
+ + {{ $t('mcp.testResult.requireApproval') }} + +
+
+ +
@@ -119,14 +133,18 @@