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 @@