Files
MyGoNavi/frontend/src/utils/mcpServerValidation.ts
Syngnat 5d4989f68f feat(ai): 增加 MCP HTTP 服务与 Docker 配置诊断
- AI 设置页新增 GoNavi MCP HTTP 服务开关与状态展示
- 后端新增 HTTP MCP 子进程生命周期管理和鉴权配置
- 增加 Docker MCP 配置诊断工具与参数提示校验
2026-06-11 18:27:13 +08:00

244 lines
7.7 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 { AIMCPServerConfig } from '../types';
import type { ParsedMCPEnvDraft } from './mcpEnvDraft';
import { splitShellLikeCommand } from './mcpCommandDraft';
export type MCPServerDraftIssueSeverity = 'error' | 'warning' | 'info';
export interface MCPServerDraftIssue {
key: string;
severity: MCPServerDraftIssueSeverity;
title: string;
detail: string;
}
export interface MCPServerDraftValidation {
issues: MCPServerDraftIssue[];
errorCount: number;
warningCount: number;
infoCount: number;
canTest: boolean;
canSave: boolean;
}
const KNOWN_LAUNCHER_COMMANDS = new Set([
'node',
'npm',
'npx',
'pnpm',
'yarn',
'bun',
'deno',
'python',
'python3',
'py',
'uv',
'uvx',
'docker',
'go',
'java',
'cmd',
'powershell',
'pwsh',
]);
const ENV_ASSIGNMENT_RE = /^(\$env:)?[A-Za-z_][A-Za-z0-9_]*=/u;
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const countIssues = (issues: MCPServerDraftIssue[], severity: MCPServerDraftIssueSeverity): number =>
issues.filter((issue) => issue.severity === severity).length;
const firstShellToken = (value: string): string => {
const { tokens } = splitShellLikeCommand(value);
return toTrimmedString(tokens[0]).toLowerCase();
};
const commandLooksLikeWholeLine = (command: string): boolean => {
const text = toTrimmedString(command);
if (!text) return false;
const { tokens } = splitShellLikeCommand(text);
if (tokens.length <= 1) return false;
const firstToken = toTrimmedString(tokens[0]).toLowerCase();
if (KNOWN_LAUNCHER_COMMANDS.has(firstToken)) return true;
if (ENV_ASSIGNMENT_RE.test(tokens[0])) return true;
return tokens.some((token, index) => index > 0 && String(token || '').startsWith('--'));
};
const argsContainEnvOrShellGlue = (args: string[]): boolean =>
args.some((arg) => {
const text = toTrimmedString(arg);
if (!text) return false;
const lower = text.toLowerCase();
return ENV_ASSIGNMENT_RE.test(text) || lower === 'env' || lower === 'set' || text === '&&' || text === ';';
});
const launcherUsuallyNeedsArgs = (command: string): boolean => {
const firstToken = firstShellToken(command);
return ['node', 'python', 'python3', 'py', 'uvx', 'npx', 'bun', 'deno', 'docker', 'go', 'java'].includes(firstToken);
};
const isDockerCommand = (command: string): boolean =>
firstShellToken(command) === 'docker';
const hasDockerRunArg = (args: string[]): boolean =>
args.some((arg) => arg.toLowerCase() === 'run');
const hasDockerInteractiveArg = (args: string[]): boolean =>
args.some((arg) => arg.toLowerCase() === '-i' || arg.toLowerCase() === '--interactive');
const hasDockerImageArg = (args: string[]): boolean => {
const runIndex = args.findIndex((arg) => arg.toLowerCase() === 'run');
const candidates = runIndex >= 0 ? args.slice(runIndex + 1) : args;
for (let index = 0; index < candidates.length; index += 1) {
const arg = candidates[index];
if (!arg || arg.startsWith('-')) {
const lower = arg.toLowerCase();
if ([
'-e',
'--env',
'--name',
'--network',
'-v',
'--volume',
'-p',
'--publish',
'--entrypoint',
'-w',
'--workdir',
'-u',
'--user',
'--platform',
'-h',
'--hostname',
].includes(lower)) {
index += 1;
}
continue;
}
return true;
}
return false;
};
export const validateMCPServerDraft = (
server: Pick<AIMCPServerConfig, 'name' | 'transport' | 'command' | 'args' | 'timeoutSeconds'>,
parsedEnvDraft?: Pick<ParsedMCPEnvDraft, 'invalidLines'>,
): MCPServerDraftValidation => {
const issues: MCPServerDraftIssue[] = [];
const command = toTrimmedString(server.command);
const args = Array.isArray(server.args) ? server.args.map(toTrimmedString).filter(Boolean) : [];
const timeoutSeconds = Number(server.timeoutSeconds);
if (!toTrimmedString(server.name)) {
issues.push({
key: 'name-missing',
severity: 'warning',
title: '服务名称为空',
detail: '建议写成 Browser、GitHub、Filesystem 这类用途名;否则保存后只能靠命令名识别。',
});
}
if (server.transport !== 'stdio') {
issues.push({
key: 'transport-unsupported',
severity: 'error',
title: '传输方式不支持',
detail: '当前 GoNavi 新增 MCP 服务只支持 stdio请保持传输方式为 stdio。',
});
}
if (!command) {
issues.push({
key: 'command-missing',
severity: 'error',
title: '启动命令未填写',
detail: '至少填写 node、uvx、python 或本机 exe 路径;脚本名和 --stdio 放到命令参数里。',
});
} else if (commandLooksLikeWholeLine(command)) {
issues.push({
key: 'command-whole-line',
severity: 'warning',
title: '启动命令可能填成了整行命令',
detail: '启动命令只填可执行程序本身;把脚本名、模块名、--stdio 和环境变量拆到命令参数或环境变量里。',
});
}
if (command && launcherUsuallyNeedsArgs(command) && args.length === 0) {
issues.push({
key: 'args-missing-for-launcher',
severity: 'warning',
title: '命令参数可能缺少脚本或模块名',
detail: 'node、python、uvx、npx 这类启动器通常还需要 server.js、-m your_server 或包名作为参数。',
});
}
if (command && isDockerCommand(command)) {
if (!hasDockerRunArg(args)) {
issues.push({
key: 'docker-run-missing',
severity: 'warning',
title: 'Docker 参数缺少 run',
detail: 'Docker MCP 通常需要 command=dockerargs 里单独填写 run、--rm、-i、镜像名和服务参数。',
});
}
if (!hasDockerInteractiveArg(args)) {
issues.push({
key: 'docker-interactive-missing',
severity: 'warning',
title: 'Docker 参数缺少 -i',
detail: 'MCP 需要持续读取标准输入docker run 场景请加 -i 或 --interactive否则工具发现可能立即断开。',
});
}
if (!hasDockerImageArg(args)) {
issues.push({
key: 'docker-image-missing',
severity: 'warning',
title: 'Docker 参数可能缺少镜像名',
detail: '请在 docker run 选项之后填写 README 提供的镜像名,例如 mcp/server-fetch:latest。',
});
}
}
if (argsContainEnvOrShellGlue(args)) {
issues.push({
key: 'args-contain-env-or-shell-glue',
severity: 'warning',
title: '命令参数里疑似混入环境变量或 Shell 连接符',
detail: 'KEY=VALUE、$env:KEY=VALUE、set、env、&& 这类内容应放到完整命令自动拆分或环境变量输入框里。',
});
}
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 3 || timeoutSeconds > 120) {
issues.push({
key: 'timeout-out-of-range',
severity: 'warning',
title: '超时时间不在推荐范围内',
detail: 'GoNavi 最终会限制在 3 到 120 秒之间;本机常规服务建议 20 秒,慢启动服务建议 45 或 60 秒。',
});
}
const invalidEnvLines = parsedEnvDraft?.invalidLines || [];
if (invalidEnvLines.length > 0) {
issues.push({
key: 'env-invalid-lines',
severity: 'error',
title: '环境变量存在无效行',
detail: `每行必须是 KEY=VALUE当前有 ${invalidEnvLines.length} 行不会保存:${invalidEnvLines.slice(0, 2).join(' / ')}`,
});
}
const errorCount = countIssues(issues, 'error');
const warningCount = countIssues(issues, 'warning');
const infoCount = countIssues(issues, 'info');
return {
issues,
errorCount,
warningCount,
infoCount,
canTest: errorCount === 0,
canSave: errorCount === 0,
};
};