mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-17 12:09:39 +08:00
- MCP HTTP 支持 schema-only 模式,远程配置默认不暴露 execute_sql - OpenClaw/Hermans 向导补充安全边界与结构模式命令 - 拆分 AI 面板错误边界和 Linux CJK 字体提示组件
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
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: '填一段随机长 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<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 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<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 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。',
|
||
],
|
||
};
|
||
};
|