mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 09:21:38 +08:00
✨ feat(mcp): 增加 MCP 服务配置实时校验
- 新增 MCP 服务草稿校验工具,识别空命令、整行命令、无效环境变量和异常超时 - 在 MCP 服务表单展示配置检查结果,并阻止明显无效配置直接测试或保存 - 补充 MCP 参数校验和卡片渲染回归测试
This commit is contained in:
@@ -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('命令参数可能缺少脚本或模块名');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
frontend/src/components/ai/AIMCPServerValidationPanel.tsx
Normal file
107
frontend/src/components/ai/AIMCPServerValidationPanel.tsx
Normal 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;
|
||||
64
frontend/src/utils/mcpServerValidation.test.ts
Normal file
64
frontend/src/utils/mcpServerValidation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
172
frontend/src/utils/mcpServerValidation.ts
Normal file
172
frontend/src/utils/mcpServerValidation.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user