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, parsedEnvDraft?: Pick, ): 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=docker,args 里单独填写 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, }; };