import type { AIMCPClientInstallStatus } from '../types'; export type MCPClientKey = 'claude-code' | 'codex' | 'openclaw' | 'hermans'; const AUTO_MCP_CLIENTS = new Set(['claude-code', 'codex']); const REMOTE_MCP_CLIENTS = new Set(['openclaw', 'hermans']); const DEFAULT_REMOTE_MCP_PUBLIC_URL = 'https://<你的域名或隧道地址>/mcp'; const DEFAULT_REMOTE_MCP_LOCAL_ADDR = '127.0.0.1:8765'; const DEFAULT_REMOTE_MCP_PATH = '/mcp'; export interface RemoteMCPClientQuickStart { displayName: string; configJson: string; configCommand: string; launchCommand: string; standaloneCommand: string; verificationSteps: string[]; securityNotes: string[]; } export interface RemoteMCPParameterGuide { key: string; title: string; required: boolean; fill: string; example: string; avoid: string; } export const REMOTE_MCP_PARAMETER_GUIDES: RemoteMCPParameterGuide[] = [ { key: 'publicUrl', title: '公网/隧道 URL', required: true, fill: '填云端 Agent 能访问到的 Streamable HTTP MCP 地址,通常以 /mcp 结尾。', example: 'https://agent-gateway.example.com/mcp', avoid: '不要填 Windows 本机的 127.0.0.1;云端 Linux 访问不到这个地址。', }, { key: 'bearerToken', title: 'Bearer Token', required: true, fill: '填一段随机长 token,Windows 启动命令和云端 Agent 配置必须一致。', example: 'Authorization: Bearer gnv_xxx', avoid: '不要使用空 token、短 token,也不要把数据库密码当 token 填进去。', }, { key: 'localAddr', title: '本机监听地址', required: true, fill: 'Windows GoNavi HTTP MCP 默认监听 127.0.0.1:8765,再交给隧道或反向代理转发。', example: DEFAULT_REMOTE_MCP_LOCAL_ADDR, avoid: '没有网关隔离时不要直接绑定 0.0.0.0 暴露到公网。', }, { key: 'path', title: 'MCP 路径', required: true, fill: '本机启动命令、隧道 URL 和云端 Agent 配置里的路径要保持一致。', example: DEFAULT_REMOTE_MCP_PATH, avoid: '不要一边用 /mcp,另一边配置 /api/mcp,路径不一致会 404。', }, { key: 'serverId', title: '服务 ID', required: false, fill: '给云端 Agent 识别这条 MCP 服务的名称,默认 gonavi 即可。', example: 'gonavi', avoid: '不要频繁改名,否则 Agent 里已有的工具引用可能失效。', }, ]; export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [ { client: 'claude-code', displayName: 'Claude Code', installMode: 'auto', installed: false, matchesCurrent: false, clientDetected: false, clientCommand: 'claude', message: '未检测到 Claude Code 用户级 GoNavi MCP 配置', }, { client: 'codex', displayName: 'Codex', installMode: 'auto', installed: false, matchesCurrent: false, clientDetected: false, clientCommand: 'codex', message: '未检测到 Codex 用户级 GoNavi MCP 配置', }, { client: 'openclaw', displayName: 'OpenClaw', installMode: 'remote', installed: false, matchesCurrent: false, clientDetected: false, clientCommand: 'openclaw', message: 'OpenClaw 通常部署在云端 Linux;请通过远程 MCP 桥接接入 Windows GoNavi,不要复制数据库密码。', }, { client: 'hermans', displayName: 'Hermans', installMode: 'remote', installed: false, matchesCurrent: false, clientDetected: false, clientCommand: 'hermans', message: 'Hermans 这类远程 Agent 请通过远程 MCP 桥接接入 Windows GoNavi,不要复制数据库密码。', }, ]; const MCP_CLIENT_ORDER: MCPClientKey[] = ['claude-code', 'codex', 'openclaw', 'hermans']; const quoteMCPCommandPart = (value: string): string => { const text = String(value || '').trim(); if (!text) { return ''; } return /[\s"]/u.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text; }; export const isMCPClientKey = (client: string): client is MCPClientKey => client === 'claude-code' || client === 'codex' || client === 'openclaw' || client === 'hermans'; export const isRemoteMCPClientStatus = (status?: Pick | null): boolean => { const client = String(status?.client || '').trim(); return status?.installMode === 'remote' || (isMCPClientKey(client) && REMOTE_MCP_CLIENTS.has(client)); }; export const supportsAutoMCPClientInstall = (status?: Pick | null): boolean => { const client = String(status?.client || '').trim(); return status?.installMode === 'auto' || (isMCPClientKey(client) && AUTO_MCP_CLIENTS.has(client)); }; const hasStatusError = (status: AIMCPClientInstallStatus): boolean => /失败|异常|错误|校验失败/u.test(String(status.message || '')); const getMCPClientPriority = (status: AIMCPClientInstallStatus): number => { if (status.matchesCurrent) { return 0; } if (status.installed && !status.matchesCurrent) { return 1; } if (status.clientDetected) { return 2; } if (hasStatusError(status)) { return 3; } return 4; }; export const normalizeMCPClientStatuses = (items?: AIMCPClientInstallStatus[]): AIMCPClientInstallStatus[] => { const baseMap = new Map( EMPTY_MCP_CLIENT_STATUSES.map((item) => [item.client, { ...item }]), ); (Array.isArray(items) ? items : []).forEach((item) => { if (!item || !item.client) { return; } const base = baseMap.get(item.client) || { client: item.client, displayName: item.client, installed: false, matchesCurrent: false, message: '', }; baseMap.set(item.client, { ...base, ...item, displayName: item.displayName || base.displayName, installMode: item.installMode || base.installMode || 'auto', clientDetected: item.clientDetected ?? base.clientDetected ?? false, clientCommand: item.clientCommand || base.clientCommand, clientPath: item.clientPath || '', message: item.message || base.message, args: Array.isArray(item.args) ? item.args : (base.args || []), }); }); return MCP_CLIENT_ORDER .map((client) => baseMap.get(client)) .filter((item): item is AIMCPClientInstallStatus => Boolean(item)); }; export const pickPreferredMCPClient = ( items: AIMCPClientInstallStatus[], current?: MCPClientKey, ): MCPClientKey => { if (current && items.some((item) => item.client === current)) { return current; } const ranked = items .filter((item): item is AIMCPClientInstallStatus & { client: MCPClientKey } => isMCPClientKey(item.client)) .slice() .sort((left, right) => { const priorityDiff = getMCPClientPriority(left) - getMCPClientPriority(right); if (priorityDiff !== 0) { return priorityDiff; } return MCP_CLIENT_ORDER.indexOf(left.client) - MCP_CLIENT_ORDER.indexOf(right.client); }); return ranked[0]?.client || 'claude-code'; }; export const formatMCPLaunchCommand = ( input?: Pick | { command?: string; args?: string[] } | null, ): string => { const command = String(input?.command || '').trim(); if (!command) { return ''; } const args = Array.isArray(input?.args) ? input.args.map((item) => String(item || '').trim()).filter(Boolean) : []; return [command, ...args].map(quoteMCPCommandPart).filter(Boolean).join(' '); }; export const buildRemoteMCPClientGuide = ( status?: Partial> | null, ): string => { const quickStart = buildRemoteMCPClientQuickStart(status); return [ `GoNavi MCP 远程接入说明 - ${quickStart.displayName}`, '', '目标:', '- 数据库连接、账号和密码继续保存在 Windows 上的 GoNavi。云端 Agent 不需要保存数据库密码。', '- 云端 Agent 只通过 MCP tools 读取 get_connections/get_databases/get_tables/get_columns/get_table_ddl 等结果。', '- 远程接入默认使用 schema-only 模式,不注册 execute_sql,适合只给 OpenClaw/Hermans 读取库表结构。', '', '当前边界:', '- GoNavi 内置 MCP 本机入口是 stdio,适合 Claude Code / Codex 这类和 GoNavi 在同一台机器上的客户端。', '- 如果 OpenClaw/Hermans 部署在云端 Linux,不能直接使用 Windows 本地 stdio 命令;可在 Windows 上启动 GoNavi Streamable HTTP 模式,再通过隧道或反向代理给云端 Agent 调用。', '', '建议接入方式:', '1. Windows 本机保持 GoNavi 可访问,由 GoNavi 读取保存连接和系统凭据。', `2. 在 Windows 或可信内网侧运行:${quickStart.launchCommand}。`, `3. 在 ${quickStart.displayName} 中添加远程 MCP Server,transport 选择 Streamable HTTP,URL 填隧道/反向代理后的 /mcp 地址,并设置 Authorization: Bearer <随机token>。`, '4. 先调用 get_connections 获取 connectionId,再调用表结构工具;不要把数据库 host/user/password 写进云端 Agent 配置。', '', '可复制配置片段(适用于支持 mcpServers JSON 的 Agent):', ...quickStart.configJson.split('\n'), '', '无 GUI / CLI 生成配置命令:', quickStart.configCommand, '', 'CLI / 服务启动命令:', quickStart.launchCommand, `或设置环境变量:GONAVI_MCP_HTTP_TOKEN=<随机token> 后运行 ${quickStart.standaloneCommand.replace(' --token <随机token>', '')}`, '如果明确需要远程执行 SQL,可去掉 --schema-only;此时 execute_sql 仍受 GoNavi AI 安全控制约束,写操作必须显式传 allowMutating=true。', '', status?.message ? `当前提示:${status.message}` : '', ].filter((line, index, lines) => line || index < lines.length - 1).join('\n'); }; export const buildRemoteMCPClientQuickStart = ( status?: Partial> | null, ): RemoteMCPClientQuickStart => { const displayName = String(status?.displayName || '远程 Agent').trim(); const client = isMCPClientKey(String(status?.client || '')) ? String(status?.client || '').trim() : 'openclaw'; const launchCommand = `GoNavi.exe mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token> --schema-only`; const standaloneCommand = `gonavi-mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token> --schema-only`; const configCommand = `GoNavi.exe mcp-server remote-config --client ${client} --url ${DEFAULT_REMOTE_MCP_PUBLIC_URL} --token <随机token> --schema-only`; const configJson = JSON.stringify({ mcpServers: { gonavi: { type: 'streamable-http', url: DEFAULT_REMOTE_MCP_PUBLIC_URL, headers: { Authorization: 'Bearer <随机token>', }, }, }, }, null, 2); return { displayName, configJson, configCommand, launchCommand, standaloneCommand, verificationSteps: [ 'Windows 本机先访问 http://127.0.0.1:8765/healthz,确认 GoNavi MCP HTTP 服务已启动。', `${displayName} 里配置 Streamable HTTP MCP,URL 指向隧道或反向代理后的 /mcp 地址。`, '先调用 get_connections 获取 connectionId,再读取 get_databases / get_tables / get_columns。', ], securityNotes: [ '数据库账号和密码仍保存在 Windows GoNavi,本段配置不要写数据库密码。', '默认 --schema-only 不注册 execute_sql,远程 Agent 只能走库表结构类工具。', 'HTTP MCP 必须使用随机 Bearer Token,并放在 HTTPS、私有网络或受控隧道后面。', '如去掉 --schema-only 开放 execute_sql,仍受 GoNavi AI 安全控制约束,写操作仍必须显式传 allowMutating=true。', ], }; };