Files
MyGoNavi/frontend/src/utils/mcpClientInstallStatus.ts
Syngnat 450d1d66b4 feat(ai): 完善远程 MCP 结构模式与面板稳定性
- MCP HTTP 支持 schema-only 模式,远程配置默认不暴露 execute_sql

- OpenClaw/Hermans 向导补充安全边界与结构模式命令

- 拆分 AI 面板错误边界和 Linux CJK 字体提示组件
2026-06-11 09:26:54 +08:00

301 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AIMCPClientInstallStatus } from '../types';
export type MCPClientKey = 'claude-code' | 'codex' | 'openclaw' | 'hermans';
const AUTO_MCP_CLIENTS = new Set<MCPClientKey>(['claude-code', 'codex']);
const REMOTE_MCP_CLIENTS = new Set<MCPClientKey>(['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: '填一段随机长 tokenWindows 启动命令和云端 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<AIMCPClientInstallStatus, 'client' | 'installMode'> | null): boolean => {
const client = String(status?.client || '').trim();
return status?.installMode === 'remote' || (isMCPClientKey(client) && REMOTE_MCP_CLIENTS.has(client));
};
export const supportsAutoMCPClientInstall = (status?: Pick<AIMCPClientInstallStatus, 'client' | 'installMode'> | 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<string, AIMCPClientInstallStatus>(
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<AIMCPClientInstallStatus, 'command' | 'args'> | { 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<Pick<AIMCPClientInstallStatus, 'client' | 'displayName' | 'message'>> | 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 Servertransport 选择 Streamable HTTPURL 填隧道/反向代理后的 /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<Pick<AIMCPClientInstallStatus, 'client' | 'displayName'>> | 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 MCPURL 指向隧道或反向代理后的 /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。',
],
};
};