From 7df524e9efe1953b7ed0c03fe4079d19acb5fb57 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 10 Jun 2026 02:01:33 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(mcp):=20=E5=A2=9E=E5=8A=A0=20M?= =?UTF-8?q?CP=20=E6=9C=8D=E5=8A=A1=E9=85=8D=E7=BD=AE=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MCP 服务草稿校验工具,识别空命令、整行命令、无效环境变量和异常超时 - 在 MCP 服务表单展示配置检查结果,并阻止明显无效配置直接测试或保存 - 补充 MCP 参数校验和卡片渲染回归测试 --- .../components/ai/AIMCPServerCard.test.tsx | 35 ++++ .../src/components/ai/AIMCPServerCard.tsx | 3 + .../components/ai/AIMCPServerFormPanel.tsx | 15 +- .../ai/AIMCPServerValidationPanel.tsx | 107 +++++++++++ .../src/utils/mcpServerValidation.test.ts | 64 +++++++ frontend/src/utils/mcpServerValidation.ts | 172 ++++++++++++++++++ 6 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/ai/AIMCPServerValidationPanel.tsx create mode 100644 frontend/src/utils/mcpServerValidation.test.ts create mode 100644 frontend/src/utils/mcpServerValidation.ts diff --git a/frontend/src/components/ai/AIMCPServerCard.test.tsx b/frontend/src/components/ai/AIMCPServerCard.test.tsx index 765c75a..ab1d104 100644 --- a/frontend/src/components/ai/AIMCPServerCard.test.tsx +++ b/frontend/src/components/ai/AIMCPServerCard.test.tsx @@ -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( + {}} + onTest={() => {}} + onSave={() => {}} + onDelete={() => {}} + />, + ); + + expect(markup).toContain('启动命令可能填成了整行命令'); + expect(markup).toContain('把脚本名、模块名、--stdio 和环境变量拆到命令参数或环境变量里'); + expect(markup).toContain('命令参数可能缺少脚本或模块名'); + }); }); diff --git a/frontend/src/components/ai/AIMCPServerCard.tsx b/frontend/src/components/ai/AIMCPServerCard.tsx index b813eaf..89ba87c 100644 --- a/frontend/src/components/ai/AIMCPServerCard.tsx +++ b/frontend/src/components/ai/AIMCPServerCard.tsx @@ -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 = ({ 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 = ({ launchPreview={launchPreview} envDraft={envDraft} parsedEnvDraft={parsedEnvDraft} + validation={validation} cardBorder={cardBorder} inputBg={inputBg} darkMode={darkMode} diff --git a/frontend/src/components/ai/AIMCPServerFormPanel.tsx b/frontend/src/components/ai/AIMCPServerFormPanel.tsx index 432ae96..b47cfec 100644 --- a/frontend/src/components/ai/AIMCPServerFormPanel.tsx +++ b/frontend/src/components/ai/AIMCPServerFormPanel.tsx @@ -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 = ({ launchPreview, envDraft, parsedEnvDraft, + validation, cardBorder, inputBg, darkMode, @@ -155,6 +159,13 @@ const AIMCPServerFormPanel: React.FC = ({ + + {serverTools.length > 0 && (
已发现工具
@@ -182,8 +193,8 @@ const AIMCPServerFormPanel: React.FC = ({
- - + + diff --git a/frontend/src/components/ai/AIMCPServerValidationPanel.tsx b/frontend/src/components/ai/AIMCPServerValidationPanel.tsx new file mode 100644 index 0000000..143a2b2 --- /dev/null +++ b/frontend/src/components/ai/AIMCPServerValidationPanel.tsx @@ -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 = ({ + validation, + cardBorder, + darkMode, + overlayTheme, +}) => { + const hasIssues = validation.issues.length > 0; + const summaryText = validation.errorCount > 0 + ? `发现 ${validation.errorCount} 个必须修复的问题,修复后才能测试或保存。` + : validation.warningCount > 0 + ? `发现 ${validation.warningCount} 个建议检查项,仍可测试和保存。` + : '当前配置可以测试和保存。'; + + return ( +
+
配置检查
+
0 ? '#dc2626' : overlayTheme.mutedText)}> + {summaryText} +
+ {hasIssues ? ( +
+ {validation.issues.map((issue) => { + const tone = getIssueTone(issue, darkMode); + return ( +
+
+ + {tone.label} + + {issue.title} +
+
{issue.detail}
+
+ ); + })} +
+ ) : null} +
+ ); +}; + +export default AIMCPServerValidationPanel; diff --git a/frontend/src/utils/mcpServerValidation.test.ts b/frontend/src/utils/mcpServerValidation.test.ts new file mode 100644 index 0000000..6e2df9d --- /dev/null +++ b/frontend/src/utils/mcpServerValidation.test.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/mcpServerValidation.ts b/frontend/src/utils/mcpServerValidation.ts new file mode 100644 index 0000000..4b0790d --- /dev/null +++ b/frontend/src/utils/mcpServerValidation.ts @@ -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, + 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 (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, + }; +};