feat(mcp): 增加 MCP 服务配置实时校验

- 新增 MCP 服务草稿校验工具,识别空命令、整行命令、无效环境变量和异常超时

- 在 MCP 服务表单展示配置检查结果,并阻止明显无效配置直接测试或保存

- 补充 MCP 参数校验和卡片渲染回归测试
This commit is contained in:
Syngnat
2026-06-10 02:01:33 +08:00
parent cb90a4ad01
commit 7df524e9ef
6 changed files with 394 additions and 2 deletions

View File

@@ -57,6 +57,9 @@ describe('AIMCPServerCard', () => {
expect(markup).toContain('不要写 export');
expect(markup).toContain('当前阶段只支持 stdio');
expect(markup).toContain('实际启动命令预览');
expect(markup).toContain('配置检查');
expect(markup).toContain('服务名称为空');
expect(markup).toContain('建议检查');
expect(markup).toContain('操作说明');
expect(markup).toContain('测试工具发现');
expect(markup).toContain('不会保存配置');
@@ -67,4 +70,36 @@ describe('AIMCPServerCard', () => {
expect(markup).toContain('node server.js --stdio');
expect(markup).toContain('$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio');
});
it('renders actionable validation when command and args are mixed together', () => {
const markup = renderToStaticMarkup(
<AIMCPServerCard
server={{
id: 'mcp-1',
name: 'Node MCP',
transport: 'stdio',
command: 'node server.js --stdio',
args: [],
env: {},
enabled: true,
timeoutSeconds: 20,
}}
serverTools={[]}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
inputBg="#fff"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
loading={false}
onChange={() => {}}
onTest={() => {}}
onSave={() => {}}
onDelete={() => {}}
/>,
);
expect(markup).toContain('启动命令可能填成了整行命令');
expect(markup).toContain('把脚本名、模块名、--stdio 和环境变量拆到命令参数或环境变量里');
expect(markup).toContain('命令参数可能缺少脚本或模块名');
});
});

View File

@@ -5,6 +5,7 @@ import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types';
import { parseMCPCommandDraft } from '../../utils/mcpCommandDraft';
import { formatMCPEnvDraft, parseMCPEnvDraft } from '../../utils/mcpEnvDraft';
import { buildMCPLaunchPreview } from '../../utils/mcpServerGuidance';
import { validateMCPServerDraft } from '../../utils/mcpServerValidation';
import AIMCPServerFormPanel from './AIMCPServerFormPanel';
import AIMCPServerGuidePanel from './AIMCPServerGuidePanel';
@@ -42,6 +43,7 @@ export const AIMCPServerCard: React.FC<AIMCPServerCardProps> = ({
const launchPreview = buildMCPLaunchPreview(server.command, server.args);
const parsedCommandDraft = parseMCPCommandDraft(rawCommandDraft);
const parsedEnvDraft = parseMCPEnvDraft(envDraft);
const validation = validateMCPServerDraft(server, parsedEnvDraft);
React.useEffect(() => {
setEnvDraft(formatMCPEnvDraft(server.env));
@@ -82,6 +84,7 @@ export const AIMCPServerCard: React.FC<AIMCPServerCardProps> = ({
launchPreview={launchPreview}
envDraft={envDraft}
parsedEnvDraft={parsedEnvDraft}
validation={validation}
cardBorder={cardBorder}
inputBg={inputBg}
darkMode={darkMode}

View File

@@ -5,7 +5,9 @@ import { DeleteOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types';
import type { ParsedMCPEnvDraft } from '../../utils/mcpEnvDraft';
import type { MCPServerDraftValidation } from '../../utils/mcpServerValidation';
import AIMCPHelpBlock, { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock';
import AIMCPServerValidationPanel from './AIMCPServerValidationPanel';
interface AIMCPServerFormPanelProps {
server: AIMCPServerConfig;
@@ -13,6 +15,7 @@ interface AIMCPServerFormPanelProps {
launchPreview: string;
envDraft: string;
parsedEnvDraft: ParsedMCPEnvDraft;
validation: MCPServerDraftValidation;
cardBorder: string;
inputBg: string;
darkMode: boolean;
@@ -31,6 +34,7 @@ const AIMCPServerFormPanel: React.FC<AIMCPServerFormPanelProps> = ({
launchPreview,
envDraft,
parsedEnvDraft,
validation,
cardBorder,
inputBg,
darkMode,
@@ -155,6 +159,13 @@ const AIMCPServerFormPanel: React.FC<AIMCPServerFormPanelProps> = ({
</div>
</AIMCPHelpBlock>
<AIMCPServerValidationPanel
validation={validation}
cardBorder={cardBorder}
darkMode={darkMode}
overlayTheme={overlayTheme}
/>
{serverTools.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}></div>
@@ -182,8 +193,8 @@ const AIMCPServerFormPanel: React.FC<AIMCPServerFormPanelProps> = ({
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={onTest} loading={loading} style={{ borderRadius: 10 }}></Button>
<Button type="primary" onClick={onSave} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}></Button>
<Button onClick={onTest} loading={loading} disabled={!validation.canTest} style={{ borderRadius: 10 }}></Button>
<Button type="primary" onClick={onSave} loading={loading} disabled={!validation.canSave} style={{ borderRadius: 10, fontWeight: 600 }}></Button>
<Popconfirm title="删除这个 MCP 服务?" okText="删除" cancelText="取消" onConfirm={onDelete}>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}></Button>
</Popconfirm>

View File

@@ -0,0 +1,107 @@
import React from 'react';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { MCPServerDraftIssue, MCPServerDraftValidation } from '../../utils/mcpServerValidation';
import { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock';
interface AIMCPServerValidationPanelProps {
validation: MCPServerDraftValidation;
cardBorder: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
}
const getIssueTone = (issue: MCPServerDraftIssue, darkMode: boolean) => {
if (issue.severity === 'error') {
return {
label: '需修复',
color: '#dc2626',
bg: darkMode ? 'rgba(220,38,38,0.18)' : 'rgba(220,38,38,0.10)',
};
}
if (issue.severity === 'warning') {
return {
label: '建议检查',
color: '#b45309',
bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)',
};
}
return {
label: '提示',
color: '#2563eb',
bg: darkMode ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.12)',
};
};
const AIMCPServerValidationPanel: React.FC<AIMCPServerValidationPanelProps> = ({
validation,
cardBorder,
darkMode,
overlayTheme,
}) => {
const hasIssues = validation.issues.length > 0;
const summaryText = validation.errorCount > 0
? `发现 ${validation.errorCount} 个必须修复的问题,修复后才能测试或保存。`
: validation.warningCount > 0
? `发现 ${validation.warningCount} 个建议检查项,仍可测试和保存。`
: '当前配置可以测试和保存。';
return (
<div
style={{
padding: '10px 12px',
borderRadius: 10,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}></div>
<div style={buildMCPHintStyle(validation.errorCount > 0 ? '#dc2626' : overlayTheme.mutedText)}>
{summaryText}
</div>
{hasIssues ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{validation.issues.map((issue) => {
const tone = getIssueTone(issue, darkMode);
return (
<div
key={issue.key}
style={{
padding: '8px 10px',
borderRadius: 10,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.78)',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span
style={{
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: tone.color,
background: tone.bg,
}}
>
{tone.label}
</span>
<span style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}>{issue.title}</span>
</div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>{issue.detail}</div>
</div>
);
})}
</div>
) : null}
</div>
);
};
export default AIMCPServerValidationPanel;

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { validateMCPServerDraft } from './mcpServerValidation';
describe('mcpServerValidation', () => {
it('blocks testing and saving when required MCP launch fields are invalid', () => {
const validation = validateMCPServerDraft({
name: 'GitHub',
transport: 'stdio',
command: '',
args: ['--stdio'],
timeoutSeconds: 20,
}, { invalidLines: [] });
expect(validation.canTest).toBe(false);
expect(validation.canSave).toBe(false);
expect(validation.errorCount).toBe(1);
expect(validation.issues.map((issue) => issue.key)).toContain('command-missing');
});
it('warns when users paste a whole command into the command field', () => {
const validation = validateMCPServerDraft({
name: 'Node',
transport: 'stdio',
command: 'node server.js --stdio',
args: [],
timeoutSeconds: 20,
}, { invalidLines: [] });
expect(validation.canTest).toBe(true);
expect(validation.warningCount).toBeGreaterThanOrEqual(1);
expect(validation.issues.map((issue) => issue.key)).toContain('command-whole-line');
expect(validation.issues.map((issue) => issue.key)).toContain('args-missing-for-launcher');
});
it('blocks save when env draft contains lines that would be silently dropped', () => {
const validation = validateMCPServerDraft({
name: 'GitHub',
transport: 'stdio',
command: 'uvx',
args: ['mcp-server-github', '--stdio'],
timeoutSeconds: 45,
}, { invalidLines: ['export GITHUB_TOKEN=abc'] });
expect(validation.canTest).toBe(false);
expect(validation.canSave).toBe(false);
expect(validation.errorCount).toBe(1);
expect(validation.issues.find((issue) => issue.key === 'env-invalid-lines')?.detail).toContain('export GITHUB_TOKEN=abc');
});
it('keeps valid drafts testable and saveable', () => {
const validation = validateMCPServerDraft({
name: 'Filesystem',
transport: 'stdio',
command: 'node',
args: ['server.js', '--stdio'],
timeoutSeconds: 20,
}, { invalidLines: [] });
expect(validation.canTest).toBe(true);
expect(validation.canSave).toBe(true);
expect(validation.errorCount).toBe(0);
});
});

View File

@@ -0,0 +1,172 @@
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',
'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', 'go', 'java'].includes(firstToken);
};
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 (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,
};
};