From 11156c941c7fcf927d3804c88c9aa18734166136 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 10 Jun 2026 22:19:24 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(mcp):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=BF=9C=E7=A8=8B=20Agent=20=E6=8E=A5=E5=85=A5=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 inspect_mcp_remote_access 内置工具,输出 Streamable HTTP MCP、Bearer Token、隧道和安全边界指引 - 注册工具目录与工具元信息,覆盖 OpenClaw/Hermans 云端 Agent 访问 Windows GoNavi 场景 - 补充远程 MCP 快照和本地工具执行器测试,清理 SQL 编辑器冗余补全常量 --- frontend/src/components/QueryEditor.tsx | 158 ----------------- .../components/ai/AIBuiltinToolsCatalog.tsx | 5 + .../aiLocalToolExecutor.aiSetupHealth.test.ts | 2 +- ...iLocalToolExecutor.mcpRemoteAccess.test.ts | 62 +++++++ .../ai/aiMCPRemoteAccessInsights.test.ts | 50 ++++++ .../ai/aiMCPRemoteAccessInsights.ts | 166 ++++++++++++++++++ ...iSnapshotInspectionAIConfigToolExecutor.ts | 21 +++ .../src/utils/aiBuiltinInspectionToolInfo.ts | 30 ++++ frontend/src/utils/aiToolRegistry.test.ts | 9 + 9 files changed, 344 insertions(+), 159 deletions(-) create mode 100644 frontend/src/components/ai/aiLocalToolExecutor.mcpRemoteAccess.test.ts create mode 100644 frontend/src/components/ai/aiMCPRemoteAccessInsights.test.ts create mode 100644 frontend/src/components/ai/aiMCPRemoteAccessInsights.ts diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index be491417..78198d4c 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -42,164 +42,6 @@ import QueryEditorTransactionSettings, { import QueryEditorTransactionToolbar from './QueryEditorTransactionToolbar'; import { useSqlEditorTransactionController } from './useSqlEditorTransactionController'; -const SQL_KEYWORDS = [ - 'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', - 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', - 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'ADD', 'MODIFY', 'CHANGE', - 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', - 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN', -]; - -// SQL 常用内置函数(通用,适用于 MySQL/PostgreSQL/Oracle/SQL Server 等主流数据源) -const SQL_FUNCTIONS: { name: string; detail: string }[] = [ - // 聚合函数 - { name: 'COUNT', detail: '聚合 - 计数' }, - { name: 'SUM', detail: '聚合 - 求和' }, - { name: 'AVG', detail: '聚合 - 平均值' }, - { name: 'MAX', detail: '聚合 - 最大值' }, - { name: 'MIN', detail: '聚合 - 最小值' }, - { name: 'GROUP_CONCAT', detail: '聚合 - 拼接分组值' }, - // 字符串函数 - { name: 'CONCAT', detail: '字符串 - 拼接' }, - { name: 'CONCAT_WS', detail: '字符串 - 带分隔符拼接' }, - { name: 'SUBSTRING', detail: '字符串 - 截取子串' }, - { name: 'SUBSTR', detail: '字符串 - 截取子串' }, - { name: 'LEFT', detail: '字符串 - 从左截取' }, - { name: 'RIGHT', detail: '字符串 - 从右截取' }, - { name: 'LENGTH', detail: '字符串 - 字节长度' }, - { name: 'CHAR_LENGTH', detail: '字符串 - 字符长度' }, - { name: 'UPPER', detail: '字符串 - 转大写' }, - { name: 'LOWER', detail: '字符串 - 转小写' }, - { name: 'TRIM', detail: '字符串 - 去空格' }, - { name: 'LTRIM', detail: '字符串 - 去左空格' }, - { name: 'RTRIM', detail: '字符串 - 去右空格' }, - { name: 'REPLACE', detail: '字符串 - 替换' }, - { name: 'REVERSE', detail: '字符串 - 反转' }, - { name: 'REPEAT', detail: '字符串 - 重复' }, - { name: 'LPAD', detail: '字符串 - 左填充' }, - { name: 'RPAD', detail: '字符串 - 右填充' }, - { name: 'INSTR', detail: '字符串 - 查找位置' }, - { name: 'LOCATE', detail: '字符串 - 查找位置' }, - { name: 'FIND_IN_SET', detail: '字符串 - 在集合中查找' }, - { name: 'FORMAT', detail: '字符串 - 数字格式化' }, - { name: 'SPACE', detail: '字符串 - 生成空格' }, - { name: 'INSERT', detail: '字符串 - 插入替换' }, - { name: 'FIELD', detail: '字符串 - 返回位置索引' }, - { name: 'ELT', detail: '字符串 - 按索引返回' }, - { name: 'HEX', detail: '字符串 - 十六进制编码' }, - { name: 'UNHEX', detail: '字符串 - 十六进制解码' }, - // 数学函数 - { name: 'ABS', detail: '数学 - 绝对值' }, - { name: 'CEIL', detail: '数学 - 向上取整' }, - { name: 'CEILING', detail: '数学 - 向上取整' }, - { name: 'FLOOR', detail: '数学 - 向下取整' }, - { name: 'ROUND', detail: '数学 - 四舍五入' }, - { name: 'TRUNCATE', detail: '数学 - 截断小数' }, - { name: 'MOD', detail: '数学 - 取模' }, - { name: 'RAND', detail: '数学 - 随机数' }, - { name: 'SIGN', detail: '数学 - 符号' }, - { name: 'POWER', detail: '数学 - 幂运算' }, - { name: 'POW', detail: '数学 - 幂运算' }, - { name: 'SQRT', detail: '数学 - 平方根' }, - { name: 'LOG', detail: '数学 - 对数' }, - { name: 'LOG2', detail: '数学 - 以2为底对数' }, - { name: 'LOG10', detail: '数学 - 以10为底对数' }, - { name: 'LN', detail: '数学 - 自然对数' }, - { name: 'EXP', detail: '数学 - e的次方' }, - { name: 'PI', detail: '数学 - 圆周率' }, - { name: 'GREATEST', detail: '数学 - 返回最大值' }, - { name: 'LEAST', detail: '数学 - 返回最小值' }, - // 日期时间函数 - { name: 'NOW', detail: '日期 - 当前日期时间' }, - { name: 'CURDATE', detail: '日期 - 当前日期' }, - { name: 'CURRENT_DATE', detail: '日期 - 当前日期' }, - { name: 'CURTIME', detail: '日期 - 当前时间' }, - { name: 'CURRENT_TIME', detail: '日期 - 当前时间' }, - { name: 'CURRENT_TIMESTAMP', detail: '日期 - 当前时间戳' }, - { name: 'SYSDATE', detail: '日期 - 系统当前时间' }, - { name: 'DATE', detail: '日期 - 提取日期部分' }, - { name: 'TIME', detail: '日期 - 提取时间部分' }, - { name: 'YEAR', detail: '日期 - 提取年份' }, - { name: 'MONTH', detail: '日期 - 提取月份' }, - { name: 'DAY', detail: '日期 - 提取天' }, - { name: 'DAYOFWEEK', detail: '日期 - 星期几(1=周日)' }, - { name: 'DAYOFYEAR', detail: '日期 - 年中第几天' }, - { name: 'HOUR', detail: '日期 - 提取小时' }, - { name: 'MINUTE', detail: '日期 - 提取分钟' }, - { name: 'SECOND', detail: '日期 - 提取秒' }, - { name: 'DATE_FORMAT', detail: '日期 - 格式化' }, - { name: 'DATE_ADD', detail: '日期 - 加日期' }, - { name: 'DATE_SUB', detail: '日期 - 减日期' }, - { name: 'DATEDIFF', detail: '日期 - 日期差(天)' }, - { name: 'TIMEDIFF', detail: '日期 - 时间差' }, - { name: 'TIMESTAMPDIFF', detail: '日期 - 时间戳差' }, - { name: 'TIMESTAMPADD', detail: '日期 - 时间戳加' }, - { name: 'STR_TO_DATE', detail: '日期 - 字符串转日期' }, - { name: 'UNIX_TIMESTAMP', detail: '日期 - Unix时间戳' }, - { name: 'FROM_UNIXTIME', detail: '日期 - 从Unix时间戳转换' }, - { name: 'LAST_DAY', detail: '日期 - 月末日期' }, - { name: 'WEEK', detail: '日期 - 第几周' }, - { name: 'QUARTER', detail: '日期 - 第几季度' }, - { name: 'ADDDATE', detail: '日期 - 加日期' }, - { name: 'SUBDATE', detail: '日期 - 减日期' }, - // 条件/流程控制函数 - { name: 'IF', detail: '条件 - 如果' }, - { name: 'IFNULL', detail: '条件 - NULL替换' }, - { name: 'NULLIF', detail: '条件 - 相等返回NULL' }, - { name: 'COALESCE', detail: '条件 - 返回第一个非NULL' }, - { name: 'CASE', detail: '条件 - 分支表达式' }, - // 类型转换 - { name: 'CAST', detail: '转换 - 类型转换' }, - { name: 'CONVERT', detail: '转换 - 类型/字符集转换' }, - // JSON 函数 - { name: 'JSON_EXTRACT', detail: 'JSON - 提取值' }, - { name: 'JSON_UNQUOTE', detail: 'JSON - 去引号' }, - { name: 'JSON_SET', detail: 'JSON - 设置值' }, - { name: 'JSON_INSERT', detail: 'JSON - 插入值' }, - { name: 'JSON_REPLACE', detail: 'JSON - 替换值' }, - { name: 'JSON_REMOVE', detail: 'JSON - 删除值' }, - { name: 'JSON_CONTAINS', detail: 'JSON - 包含判断' }, - { name: 'JSON_OBJECT', detail: 'JSON - 构建对象' }, - { name: 'JSON_ARRAY', detail: 'JSON - 构建数组' }, - { name: 'JSON_LENGTH', detail: 'JSON - 元素个数' }, - { name: 'JSON_TYPE', detail: 'JSON - 值类型' }, - { name: 'JSON_VALID', detail: 'JSON - 验证' }, - { name: 'JSON_KEYS', detail: 'JSON - 获取键列表' }, - // 加密/哈希函数 - { name: 'MD5', detail: '加密 - MD5哈希' }, - { name: 'SHA1', detail: '加密 - SHA1哈希' }, - { name: 'SHA2', detail: '加密 - SHA2哈希' }, - { name: 'UUID', detail: '工具 - 生成UUID' }, - // 信息函数 - { name: 'DATABASE', detail: '信息 - 当前数据库' }, - { name: 'USER', detail: '信息 - 当前用户' }, - { name: 'VERSION', detail: '信息 - MySQL版本' }, - { name: 'CONNECTION_ID', detail: '信息 - 连接ID' }, - { name: 'LAST_INSERT_ID', detail: '信息 - 最后插入ID' }, - { name: 'ROW_COUNT', detail: '信息 - 影响行数' }, - { name: 'FOUND_ROWS', detail: '信息 - 匹配总行数' }, - { name: 'CHARSET', detail: '信息 - 字符集' }, - { name: 'COLLATION', detail: '信息 - 排序规则' }, - // 窗口函数 - { name: 'ROW_NUMBER', detail: '窗口 - 行号' }, - { name: 'RANK', detail: '窗口 - 排名(有间隔)' }, - { name: 'DENSE_RANK', detail: '窗口 - 排名(无间隔)' }, - { name: 'NTILE', detail: '窗口 - 分桶' }, - { name: 'LAG', detail: '窗口 - 前一行' }, - { name: 'LEAD', detail: '窗口 - 后一行' }, - { name: 'FIRST_VALUE', detail: '窗口 - 第一个值' }, - { name: 'LAST_VALUE', detail: '窗口 - 最后一个值' }, - { name: 'NTH_VALUE', detail: '窗口 - 第N个值' }, - // 其他 - { name: 'DISTINCT', detail: '修饰 - 去重' }, - { name: 'EXISTS', detail: '修饰 - 存在判断' }, - { name: 'BETWEEN', detail: '修饰 - 范围判断' }, - { name: 'LIKE', detail: '修饰 - 模式匹配' }, - { name: 'REGEXP', detail: '修饰 - 正则匹配' }, - { name: 'BENCHMARK', detail: '工具 - 性能测试' }, - { name: 'SLEEP', detail: '工具 - 延时' }, -]; - // HMR 重载时释放旧注册避免补全和 hover 内容重复 const _g = globalThis as any; const SQL_COMPLETION_PROVIDER_VERSION = '20260603-hover-singleton-v1'; diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index 1761af46..14aefae0 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -75,6 +75,11 @@ const BUILTIN_TOOL_FLOWS = [ steps: 'inspect_mcp_setup → inspect_ai_runtime', description: '适合先确认当前配置了哪些 MCP 服务、哪些已启用、外部客户端有没有写入当前 GoNavi 路径,再结合运行时工具列表判断为什么某个工具没暴露出来。', }, + { + title: '远程 Agent 接入 GoNavi MCP', + steps: 'inspect_mcp_remote_access → inspect_mcp_setup → inspect_ai_safety', + description: '适合 OpenClaw/Hermans 部署在云端 Linux,但数据库连接和密码只在 Windows GoNavi 本机时,先生成 HTTP MCP、Bearer Token、隧道和安全边界指引。', + }, { title: '新增 MCP 填写指引', steps: 'inspect_mcp_authoring_guide → inspect_mcp_draft → inspect_mcp_setup', diff --git a/frontend/src/components/ai/aiLocalToolExecutor.aiSetupHealth.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.aiSetupHealth.test.ts index aa9fdcd0..2d54b7e3 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.aiSetupHealth.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.aiSetupHealth.test.ts @@ -109,7 +109,7 @@ describe('aiLocalToolExecutor inspect_ai_setup_health', () => { expect(result.content).toContain('"chatStatus":"ready"'); expect(result.content).toContain('"enabledMCPServerCount":1'); expect(result.content).toContain('"currentExternalClientCount":0'); - expect(result.content).toContain('如需让外部 CLI 使用 GoNavi MCP'); + expect(result.content).toContain('如需让外部 Agent 使用 GoNavi MCP'); expect(result.content).toContain('当前聊天已就绪,但还没有挂载任何表结构上下文'); }); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.mcpRemoteAccess.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.mcpRemoteAccess.test.ts new file mode 100644 index 00000000..1da3858e --- /dev/null +++ b/frontend/src/components/ai/aiLocalToolExecutor.mcpRemoteAccess.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AIToolCall, SavedConnection } from '../../types'; +import { executeLocalAIToolCall } from './aiLocalToolExecutor'; + +const buildConnection = (): SavedConnection => ({ + id: 'conn-1', + name: '主库', + config: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + user: 'root', + }, +}); + +const buildToolCall = (name: string, args: Record): AIToolCall => ({ + id: `call-${name}`, + type: 'function', + function: { + name, + arguments: JSON.stringify(args), + }, +}); + +describe('aiLocalToolExecutor inspect_mcp_remote_access', () => { + it('returns remote mcp access guidance through the unified local tool executor', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_mcp_remote_access', { + publicUrl: 'https://mcp.example.com/gonavi', + exposeStrategy: 'tailscale', + tokenConfigured: true, + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + getMCPClientInstallStatuses: vi.fn().mockResolvedValue([ + { + client: 'hermans', + displayName: 'Hermans', + installMode: 'remote', + installed: false, + matchesCurrent: false, + clientDetected: false, + clientCommand: 'hermans', + message: 'Hermans 通过远程 MCP 桥接接入', + }, + ]), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"mode":"streamable-http"'); + expect(result.content).toContain('"publicUrl":"https://mcp.example.com/gonavi/mcp"'); + expect(result.content).toContain('Authorization: Bearer <随机token>'); + expect(result.content).toContain('"displayName":"Hermans"'); + expect(result.content).toContain('"cloudAgentNeedsDatabasePassword":false'); + }); +}); diff --git a/frontend/src/components/ai/aiMCPRemoteAccessInsights.test.ts b/frontend/src/components/ai/aiMCPRemoteAccessInsights.test.ts new file mode 100644 index 00000000..d02a8ec2 --- /dev/null +++ b/frontend/src/components/ai/aiMCPRemoteAccessInsights.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMCPRemoteAccessSnapshot } from './aiMCPRemoteAccessInsights'; + +describe('aiMCPRemoteAccessInsights', () => { + it('builds a secure remote mcp access guide for cloud agents', () => { + const snapshot = buildMCPRemoteAccessSnapshot({ + publicUrl: 'https://mcp.example.com/gonavi', + exposeStrategy: 'cloudflare_tunnel', + tokenConfigured: true, + mcpClientStatuses: [ + { + client: 'openclaw', + displayName: 'OpenClaw', + installMode: 'remote', + installed: false, + matchesCurrent: false, + clientDetected: false, + clientCommand: 'openclaw', + message: 'OpenClaw 通常部署在云端 Linux', + }, + ], + }); + + expect(snapshot.mode).toBe('streamable-http'); + expect(snapshot.endpoint.publicUrl).toBe('https://mcp.example.com/gonavi/mcp'); + expect(snapshot.endpoint.authHeader).toBe('Authorization: Bearer <随机token>'); + expect(snapshot.launchCommands.appBinary).toContain('GoNavi.exe mcp-server http'); + expect(snapshot.selectedStrategy.key).toBe('cloudflare_tunnel'); + expect(snapshot.remoteClients.some((client) => client.client === 'openclaw')).toBe(true); + expect(snapshot.remoteClients.find((client) => client.client === 'openclaw')?.guide).toContain('数据库连接、账号和密码继续保存在 Windows'); + expect(snapshot.securityBoundary.databaseSecretsStayLocal).toBe(true); + expect(snapshot.securityBoundary.cloudAgentNeedsDatabasePassword).toBe(false); + expect(snapshot.securityBoundary.mutatingSqlStillRequiresAllowMutating).toBe(true); + expect(snapshot.warnings).toHaveLength(0); + expect(snapshot.nextActions.join('\n')).toContain('不要把数据库密码复制到云端 Agent'); + }); + + it('warns when public url or bearer token readiness is missing', () => { + const snapshot = buildMCPRemoteAccessSnapshot({ + tokenConfigured: false, + mcpClientStatuses: [], + }); + + expect(snapshot.endpoint.localUrl).toBe('http://127.0.0.1:8765/mcp'); + expect(snapshot.remoteClients.map((client) => client.client)).toEqual(['openclaw', 'hermans']); + expect(snapshot.warnings).toContain('尚未提供云端 Agent 可访问的 MCP URL;远程 Agent 不能直接访问 Windows 本机 127.0.0.1。'); + expect(snapshot.warnings).toContain('尚未确认 Bearer Token;HTTP MCP 必须配置随机 token,不能无鉴权暴露。'); + }); +}); diff --git a/frontend/src/components/ai/aiMCPRemoteAccessInsights.ts b/frontend/src/components/ai/aiMCPRemoteAccessInsights.ts new file mode 100644 index 00000000..a626181f --- /dev/null +++ b/frontend/src/components/ai/aiMCPRemoteAccessInsights.ts @@ -0,0 +1,166 @@ +import type { AIMCPClientInstallStatus } from '../../types'; +import { + buildRemoteMCPClientGuide, + isRemoteMCPClientStatus, + normalizeMCPClientStatuses, +} from '../../utils/mcpClientInstallStatus'; + +type MCPRemoteExposeStrategyKey = + | 'reverse_proxy' + | 'ssh_reverse_tunnel' + | 'cloudflare_tunnel' + | 'tailscale' + | 'custom'; + +const DEFAULT_HTTP_ADDR = '127.0.0.1:8765'; +const DEFAULT_HTTP_PATH = '/mcp'; + +const REMOTE_EXPOSE_STRATEGIES: Array<{ + key: MCPRemoteExposeStrategyKey; + title: string; + detail: string; + risk: string; +}> = [ + { + key: 'reverse_proxy', + title: '内网反向代理', + detail: '适合 Windows GoNavi 和云端 Agent 之间已有可信内网或网关的团队环境。', + risk: '需要网关层继续限制来源 IP、TLS 和 Bearer Token,不要裸露公网。', + }, + { + key: 'ssh_reverse_tunnel', + title: 'SSH 反向隧道', + detail: '适合临时把 Windows 本机的 127.0.0.1:8765 映射到云端 Linux。配置简单,但要保证 SSH 账号和端口受控。', + risk: '隧道断开后云端 Agent 会不可用,适合 PoC 或受控运维环境。', + }, + { + key: 'cloudflare_tunnel', + title: 'Cloudflare Tunnel', + detail: '适合没有固定公网入口的 Windows 机器,通过 Cloudflare Access 叠加身份校验。', + risk: '必须启用 Access / Zero Trust 规则,不能只依赖随机 URL。', + }, + { + key: 'tailscale', + title: 'Tailscale / WireGuard', + detail: '适合把 Windows GoNavi 和云端 Agent 放进同一个私有网络,优先走内网地址。', + risk: '需要控制 ACL,只允许目标 Agent 访问 GoNavi MCP 端口。', + }, + { + key: 'custom', + title: '自定义桥接', + detail: '适合已有企业网关、堡垒机或专用 MCP 网关的环境。', + risk: '需要明确 TLS、鉴权、审计和来源限制,避免把本机数据库能力暴露给未知 Agent。', + }, +]; + +const normalizePath = (value: unknown): string => { + const raw = String(value || DEFAULT_HTTP_PATH).trim(); + if (!raw) { + return DEFAULT_HTTP_PATH; + } + return raw.startsWith('/') ? raw : `/${raw}`; +}; + +const normalizeLocalAddr = (value: unknown): string => { + const raw = String(value || DEFAULT_HTTP_ADDR).trim(); + return raw || DEFAULT_HTTP_ADDR; +}; + +const normalizePublicUrl = (value: unknown, path: string): string => { + const raw = String(value || '').trim(); + if (!raw) { + return ''; + } + const normalizedPath = normalizePath(path); + const withoutTrailingSlash = raw.replace(/\/+$/u, ''); + return withoutTrailingSlash.endsWith(normalizedPath) + ? withoutTrailingSlash + : `${withoutTrailingSlash}${normalizedPath}`; +}; + +const buildHttpLaunchCommand = (binary: string, addr: string, path: string): string => + `${binary} mcp-server http --addr ${addr} --path ${path} --token <随机token>`; + +const buildStandaloneLaunchCommand = (addr: string, path: string): string => + `gonavi-mcp-server http --addr ${addr} --path ${path} --token <随机token>`; + +const resolveStrategy = (value: unknown) => { + const key = String(value || '').trim() as MCPRemoteExposeStrategyKey; + return REMOTE_EXPOSE_STRATEGIES.find((item) => item.key === key) || REMOTE_EXPOSE_STRATEGIES[0]; +}; + +export const buildMCPRemoteAccessSnapshot = (params: { + mcpClientStatuses?: AIMCPClientInstallStatus[]; + publicUrl?: string; + localAddr?: string; + path?: string; + exposeStrategy?: MCPRemoteExposeStrategyKey | string; + tokenConfigured?: boolean; +} = {}) => { + const localAddr = normalizeLocalAddr(params.localAddr); + const path = normalizePath(params.path); + const localUrl = `http://${localAddr}${path}`; + const publicUrl = normalizePublicUrl(params.publicUrl, path); + const selectedStrategy = resolveStrategy(params.exposeStrategy); + const clientStatuses = normalizeMCPClientStatuses(params.mcpClientStatuses); + const remoteClients = clientStatuses + .filter(isRemoteMCPClientStatus) + .map((status) => ({ + client: status.client, + displayName: status.displayName, + installMode: status.installMode || 'remote', + message: status.message || '', + guide: buildRemoteMCPClientGuide(status), + })); + + const warnings: string[] = []; + const nextActions: string[] = [ + '在 Windows 本机启动 GoNavi MCP HTTP 模式,并确认 /healthz 可访问。', + '通过隧道、反向代理或私有网络只暴露 /mcp 给指定云端 Agent。', + '在 OpenClaw/Hermans 里配置 Streamable HTTP MCP URL 和 Authorization Bearer Token。', + '先调用 get_connections 获取 connectionId,再读取库表结构;不要把数据库密码复制到云端 Agent。', + ]; + + if (!publicUrl) { + warnings.push('尚未提供云端 Agent 可访问的 MCP URL;远程 Agent 不能直接访问 Windows 本机 127.0.0.1。'); + } else if (!/^https:\/\//iu.test(publicUrl) && !/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])/iu.test(publicUrl)) { + warnings.push('远程 MCP URL 不是 HTTPS;如果不是私有网络地址,建议加 TLS 或放到受控隧道后面。'); + } + + if (params.tokenConfigured === false) { + warnings.push('尚未确认 Bearer Token;HTTP MCP 必须配置随机 token,不能无鉴权暴露。'); + } + + return { + mode: 'streamable-http', + message: publicUrl + ? `远程 Agent 应通过 ${publicUrl} 访问 GoNavi MCP,并使用 Bearer Token 鉴权` + : '远程 Agent 需要通过受控隧道或反向代理访问 Windows GoNavi MCP HTTP 入口', + endpoint: { + localAddr, + path, + localUrl, + publicUrl, + healthCheckPath: '/healthz', + authHeader: 'Authorization: Bearer <随机token>', + }, + launchCommands: { + appBinary: buildHttpLaunchCommand('GoNavi.exe', localAddr, path), + standaloneBinary: buildStandaloneLaunchCommand(localAddr, path), + tokenEnvFallback: 'GONAVI_MCP_HTTP_TOKEN=<随机token> gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp', + }, + selectedStrategy, + exposeStrategies: REMOTE_EXPOSE_STRATEGIES, + remoteClients, + securityBoundary: { + databaseSecretsStayLocal: true, + cloudAgentNeedsDatabasePassword: false, + httpBearerTokenRequired: true, + executeSqlStillRequiresAISafetyPolicy: true, + mutatingSqlStillRequiresAllowMutating: true, + recommendedBindAddress: '127.0.0.1,除非前面有受控网关或私有网络', + }, + warnings, + nextActions, + }; +}; diff --git a/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts index 94fd88f5..838f2279 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts @@ -16,6 +16,7 @@ import { buildMCPAuthoringGuideSnapshot } from './aiMCPAuthoringGuideInsights'; import { buildMCPDraftInspectionSnapshot } from './aiMCPDraftInspectionInsights'; import { buildAISetupHealthSnapshot } from './aiSetupHealthInsights'; import { buildMCPSetupSnapshot } from './aiMCPInsights'; +import { buildMCPRemoteAccessSnapshot } from './aiMCPRemoteAccessInsights'; import { buildMCPToolSchemaSnapshot } from './aiMCPToolSchemaInsights'; import type { AISnapshotInspectionRuntime, @@ -55,6 +56,11 @@ const loadMCPSetupState = async (runtime: AISnapshotInspectionRuntime | undefine : Promise.resolve(undefined), ]); +const loadMCPClientInstallStatuses = async (runtime: AISnapshotInspectionRuntime | undefined) => + typeof runtime?.getMCPClientInstallStatuses === 'function' + ? runtime.getMCPClientInstallStatuses() + : undefined; + export async function executeAIConfigSnapshotToolCall( options: ExecuteAIConfigSnapshotToolCallOptions, ): Promise { @@ -167,6 +173,20 @@ export async function executeAIConfigSnapshotToolCall( success: true, }; } + case 'inspect_mcp_remote_access': { + const mcpClientInstallStatuses = await loadMCPClientInstallStatuses(runtime); + return { + content: JSON.stringify(buildMCPRemoteAccessSnapshot({ + mcpClientStatuses: Array.isArray(mcpClientInstallStatuses) ? mcpClientInstallStatuses : [], + publicUrl: args.publicUrl, + localAddr: args.localAddr, + path: args.path, + exposeStrategy: args.exposeStrategy, + tokenConfigured: args.tokenConfigured, + })), + success: true, + }; + } case 'inspect_mcp_authoring_guide': return { content: JSON.stringify(buildMCPAuthoringGuideSnapshot()), @@ -208,6 +228,7 @@ export async function executeAIConfigSnapshotToolCall( inspect_ai_providers: '读取当前 AI 供应商配置失败', inspect_ai_chat_readiness: '读取 AI 聊天发送前置状态失败', inspect_mcp_setup: '读取 MCP 配置状态失败', + inspect_mcp_remote_access: '读取 MCP 远程接入指引失败', inspect_mcp_authoring_guide: '读取 MCP 新增填写指引失败', inspect_mcp_draft: '校验 MCP 新增草稿失败', inspect_mcp_tool_schema: '读取 MCP 工具参数 schema 失败', diff --git a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts index 61955890..08446f68 100644 --- a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts @@ -128,6 +128,36 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_mcp_remote_access", + icon: "🌉", + desc: "查看 OpenClaw/Hermans 远程 MCP 接入方式", + detail: + "返回 GoNavi Streamable HTTP MCP 的本机启动命令、远程 URL/鉴权填写方式、OpenClaw/Hermans 云端 Agent 接入边界、可选桥接方案和安全提醒。适合用户说“OpenClaw 在云上怎么连 Windows GoNavi”“不要把数据库密码交给 Agent”“HTTP MCP 该怎么暴露”时先读这份远程接入快照。", + params: "publicUrl?, localAddr?, path?, exposeStrategy?, tokenConfigured?", + tool: { + type: "function", + function: { + name: "inspect_mcp_remote_access", + description: + "读取 GoNavi MCP 远程 Agent 接入快照,返回 Streamable HTTP 模式启动命令、/mcp URL、Bearer Token 鉴权要求、OpenClaw/Hermans 云端接入步骤、数据库密码留在 Windows 本机的安全边界,以及隧道/反向代理/Tailscale 等暴露方式的风险提示。适用于用户提到 OpenClaw、Hermans、云端 Linux Agent、远程 MCP、不要复制数据库密码、或本机 GoNavi 如何给外部 Agent 访问表结构时优先调用。", + parameters: { + type: "object", + properties: { + publicUrl: { type: "string", description: "可选,云端 Agent 最终能访问的 HTTPS 或私有网络 URL;如果没带 /mcp,工具会按 path 补上" }, + localAddr: { type: "string", description: "可选,Windows 本机 HTTP MCP 监听地址,默认 127.0.0.1:8765;不建议直接绑定 0.0.0.0" }, + path: { type: "string", description: "可选,Streamable HTTP MCP 路径,默认 /mcp" }, + exposeStrategy: { + type: "string", + enum: ["reverse_proxy", "ssh_reverse_tunnel", "cloudflare_tunnel", "tailscale", "custom"], + description: "可选,计划使用的远程暴露方式,用于返回对应风险提醒", + }, + tokenConfigured: { type: "boolean", description: "可选,是否已经准备随机 Bearer Token;传 false 会返回鉴权告警" }, + }, + }, + }, + }, + }, { name: "inspect_mcp_authoring_guide", icon: "🧭", diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index 70e2354e..52cb54b2 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -31,6 +31,14 @@ describe('aiToolRegistry', () => { expect(info?.tool.function.description).toContain('外部客户端'); }); + it('registers the mcp-remote-access inspector as a builtin tool', () => { + const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_mcp_remote_access'); + expect(info).toBeTruthy(); + expect(info?.desc).toContain('OpenClaw/Hermans'); + expect(info?.tool.function.description).toContain('Bearer Token'); + expect(info?.tool.function.parameters?.properties?.exposeStrategy?.enum).toContain('cloudflare_tunnel'); + }); + it('registers the mcp-authoring-guide inspector as a builtin tool', () => { const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_mcp_authoring_guide'); expect(info).toBeTruthy(); @@ -201,6 +209,7 @@ describe('aiToolRegistry', () => { expect(tools.some((item) => item.function.name === 'inspect_ai_providers')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_ai_chat_readiness')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_mcp_setup')).toBe(true); + expect(tools.some((item) => item.function.name === 'inspect_mcp_remote_access')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_mcp_authoring_guide')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_mcp_draft')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_mcp_tool_schema')).toBe(true);