From 9038fe1bdf2b7e971a04fceb0f732067c96f28a6 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 11 Jun 2026 20:53:43 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E5=A2=9E=E5=BC=BA=20MC?= =?UTF-8?q?P=20=E8=8D=89=E7=A8=BF=E6=A0=A1=E9=AA=8C=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽取 MCP 草稿 seed 构建逻辑供 UI 和内置工具复用 - inspect_mcp_draft 返回脱敏 suggestedServerSeed - 同步 slash 命令、系统指导和回归测试 --- .../ai/AIMCPQuickAddServerPanel.test.tsx | 34 +----- .../ai/AIMCPQuickAddServerPanel.tsx | 89 +------------- ...calToolExecutor.aiConfigInspection.test.ts | 7 +- .../ai/aiMCPDraftInspectionInsights.test.ts | 16 +++ .../ai/aiMCPDraftInspectionInsights.ts | 36 +++++- frontend/src/components/ai/aiSlashCommands.ts | 2 +- .../ai/aiSystemContextMessages.test.ts | 2 +- .../ai/aiSystemInspectionGuidance.ts | 2 +- .../utils/aiBuiltinInspectionMcpToolInfo.ts | 4 +- frontend/src/utils/mcpServerDraftSeed.test.ts | 54 +++++++++ frontend/src/utils/mcpServerDraftSeed.ts | 111 ++++++++++++++++++ 11 files changed, 228 insertions(+), 129 deletions(-) create mode 100644 frontend/src/utils/mcpServerDraftSeed.test.ts create mode 100644 frontend/src/utils/mcpServerDraftSeed.ts diff --git a/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx b/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx index 391e830..1d5c835 100644 --- a/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx @@ -2,9 +2,8 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; -import { parseMCPCommandDraft } from '../../utils/mcpCommandDraft'; import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; -import AIMCPQuickAddServerPanel, { buildMCPQuickAddServerSeed } from './AIMCPQuickAddServerPanel'; +import AIMCPQuickAddServerPanel from './AIMCPQuickAddServerPanel'; describe('AIMCPQuickAddServerPanel', () => { it('renders a top-level full-command entry for creating MCP drafts', () => { @@ -26,35 +25,4 @@ describe('AIMCPQuickAddServerPanel', () => { expect(markup).toContain('$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio'); expect(markup).toContain('解析并新增草稿'); }); - - it('builds an editable draft seed from a parsed uvx command with env vars', () => { - const parsed = parseMCPCommandDraft('$env:GITHUB_TOKEN=***; uvx mcp-server-github --stdio'); - - expect(parsed.ok).toBe(true); - const seed = buildMCPQuickAddServerSeed(parsed.draft!); - - expect(seed).toMatchObject({ - name: 'mcp-server-github', - transport: 'stdio', - command: 'uvx', - args: ['mcp-server-github', '--stdio'], - env: { GITHUB_TOKEN: '***' }, - enabled: true, - timeoutSeconds: 20, - }); - }); - - it('uses a wider default timeout and image-based name for docker drafts', () => { - const parsed = parseMCPCommandDraft('docker run --rm -i -e API_KEY=*** mcp/server-fetch:latest'); - - expect(parsed.ok).toBe(true); - const seed = buildMCPQuickAddServerSeed(parsed.draft!); - - expect(seed).toMatchObject({ - name: 'server-fetch:latest', - command: 'docker', - args: ['run', '--rm', '-i', '-e', 'API_KEY=***', 'mcp/server-fetch:latest'], - timeoutSeconds: 45, - }); - }); }); diff --git a/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx b/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx index 1c7651c..c6275c2 100644 --- a/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx +++ b/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx @@ -5,10 +5,10 @@ import { PlusOutlined } from '@ant-design/icons'; import type { AIMCPServerConfig } from '../../types'; import { parseMCPCommandDraft, - type ParsedMCPCommandDraft, type ParseMCPCommandDraftResult, } from '../../utils/mcpCommandDraft'; import { MCP_COMMAND_PARSE_EXAMPLE } from '../../utils/mcpServerGuidance'; +import { buildMCPQuickAddServerSeed } from '../../utils/mcpServerDraftSeed'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import AIMCPCommandDraftPreview from './AIMCPCommandDraftPreview'; import { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock'; @@ -22,93 +22,6 @@ interface AIMCPQuickAddServerPanelProps { onAddServer: (seed?: Partial) => void; } -const stripCommandSuffix = (value: string): string => - value.replace(/\.(exe|cmd|bat|ps1|c?m?[jt]s|py)$/iu, ''); - -const toDisplayNamePart = (value: string): string => { - const text = String(value || '').trim(); - if (!text) return ''; - const lastPathPart = text.split(/[\\/]/u).filter(Boolean).pop() || text; - const packagePart = lastPathPart.includes('/') ? lastPathPart.split('/').filter(Boolean).pop() || lastPathPart : lastPathPart; - return stripCommandSuffix(packagePart).replace(/^@/u, '').trim(); -}; - -const findDockerImageArg = (args: string[]): string => { - const runIndex = args.findIndex((arg) => arg.toLowerCase() === 'run'); - const candidates = runIndex >= 0 ? args.slice(runIndex + 1) : args; - const optionsWithValue = new Set([ - '-e', - '--env', - '--name', - '--network', - '-v', - '--volume', - '-p', - '--publish', - '--entrypoint', - '-w', - '--workdir', - '-u', - '--user', - '--platform', - '-h', - '--hostname', - ]); - - for (let index = 0; index < candidates.length; index += 1) { - const arg = String(candidates[index] || '').trim(); - if (!arg) continue; - if (arg.startsWith('-')) { - if (optionsWithValue.has(arg.toLowerCase())) { - index += 1; - } - continue; - } - if (arg.includes('=') || arg.toLowerCase() === 'run') { - continue; - } - return arg; - } - return ''; -}; - -const pickDraftNameCandidate = (draft: ParsedMCPCommandDraft): string => { - const commandName = toDisplayNamePart(draft.command).toLowerCase(); - const args = draft.args || []; - - if (['npx', 'npm', 'pnpm', 'yarn', 'uvx', 'uv'].includes(commandName)) { - return args.find((arg) => arg && !arg.startsWith('-') && arg.toLowerCase() !== 'stdio') || draft.command; - } - if (['node', 'bun', 'deno'].includes(commandName)) { - return args.find((arg) => arg && !arg.startsWith('-') && arg.toLowerCase() !== 'stdio') || draft.command; - } - if (['python', 'python3', 'py'].includes(commandName)) { - const moduleFlagIndex = args.findIndex((arg) => arg === '-m'); - return (moduleFlagIndex >= 0 ? args[moduleFlagIndex + 1] : '') || args.find((arg) => arg && !arg.startsWith('-')) || draft.command; - } - if (commandName === 'docker') { - return findDockerImageArg(args) || draft.command; - } - return draft.command; -}; - -export const buildMCPQuickAddServerSeed = ( - draft: ParsedMCPCommandDraft, -): Partial => { - const commandName = toDisplayNamePart(draft.command).toLowerCase(); - const namePart = toDisplayNamePart(pickDraftNameCandidate(draft)) || 'MCP 服务'; - - return { - name: namePart, - transport: 'stdio', - command: draft.command, - args: draft.args, - env: draft.env, - enabled: true, - timeoutSeconds: commandName === 'docker' ? 45 : 20, - }; -}; - const renderParseSummary = ( rawCommandDraft: string, parsedCommandDraft: ParseMCPCommandDraftResult, diff --git a/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts index 7673a40..ce409e5 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts @@ -356,7 +356,7 @@ describe('aiLocalToolExecutor AI config inspection tools', () => { expect(result.content).toContain('"supportsWholeCommandAutoSplit":true'); expect(result.content).toContain('"fullCommandPasteExample":"$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio"'); expect(result.content).toContain('"title":"启动命令"'); - expect(result.content).toContain('"example":"npx / node / uvx / python"'); + expect(result.content).toContain('"example":"npx / node / uvx / python / docker"'); expect(result.content).toContain('PowerShell $env:KEY=VALUE;'); expect(result.content).toContain('"title":"npx 包"'); expect(result.content).toContain('"exampleLaunchPreview":"npx -y @modelcontextprotocol/server-filesystem --stdio"'); @@ -384,6 +384,11 @@ describe('aiLocalToolExecutor AI config inspection tools', () => { expect(result.content).toContain('"args":["mcp-server-github","--stdio"]'); expect(result.content).toContain('"envKeys":["GITHUB_TOKEN"]'); expect(result.content).toContain('"launchCommandPreview":"uvx mcp-server-github --stdio"'); + expect(result.content).toContain('"suggestedServerSeed"'); + expect(result.content).toContain('"name":"mcp-server-github"'); + expect(result.content).toContain('"env":{"GITHUB_TOKEN":"***"}'); + expect(result.content).toContain('"fullCommand":"GITHUB_TOKEN=*** uvx mcp-server-github --stdio"'); + expect(result.content).not.toContain('ghp test'); expect(result.content).toContain('"recommendedTemplate":{"key":"uvx"'); expect(result.content).toContain('"canSave":true'); }); diff --git a/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts b/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts index 1d2b0c5..8fdaafa 100644 --- a/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts +++ b/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts @@ -15,9 +15,19 @@ describe('aiMCPDraftInspectionInsights', () => { args: ['mcp-server-github', '--stdio'], envKeys: ['GITHUB_TOKEN'], }); + expect(snapshot.input.fullCommand).toBe('GITHUB_TOKEN=*** uvx mcp-server-github --stdio'); expect(snapshot.draft.launchCommandPreview).toBe('uvx mcp-server-github --stdio'); expect(snapshot.draft.envKeys).toEqual(['GITHUB_TOKEN']); expect(snapshot.draft.timeoutSeconds).toBe(45); + expect(snapshot.draft.suggestedServerSeed).toMatchObject({ + name: 'mcp-server-github', + transport: 'stdio', + command: 'uvx', + args: ['mcp-server-github', '--stdio'], + env: { GITHUB_TOKEN: '***' }, + envRedacted: true, + timeoutSeconds: 45, + }); expect(snapshot.draft.recommendedTemplate).toMatchObject({ key: 'uvx', title: 'uvx 工具', @@ -25,6 +35,7 @@ describe('aiMCPDraftInspectionInsights', () => { }); expect(snapshot.validation.canSave).toBe(true); expect(snapshot.nextActions).toContain('当前草稿可以保存并测试工具发现;如果发现 0 个工具,再检查服务是否支持 stdio。'); + expect(JSON.stringify(snapshot)).not.toContain('ghp test'); }); it('validates split fields and returns concrete next actions for common mistakes', () => { @@ -60,6 +71,11 @@ describe('aiMCPDraftInspectionInsights', () => { key: 'docker', title: 'Docker 镜像', }); + expect(snapshot.draft.suggestedServerSeed).toMatchObject({ + name: 'docker', + command: 'docker', + timeoutSeconds: 10, + }); expect(snapshot.validation.issues.map((issue) => issue.key)).toContain('docker-interactive-missing'); expect(snapshot.validation.issues.map((issue) => issue.key)).toContain('docker-image-missing'); expect(snapshot.nextActions.join('\n')).toContain('Docker MCP 的 args 里补 -i'); diff --git a/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts b/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts index 5db9f4a..bb3e9de 100644 --- a/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts +++ b/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts @@ -1,7 +1,8 @@ import type { AIMCPServerConfig } from '../../types'; -import { parseMCPCommandDraft } from '../../utils/mcpCommandDraft'; +import { parseMCPCommandDraft, type ParseMCPCommandDraftResult } from '../../utils/mcpCommandDraft'; import { parseMCPEnvDraft } from '../../utils/mcpEnvDraft'; import { buildMCPLaunchPreview } from '../../utils/mcpServerGuidance'; +import { buildMCPServerDraftSeed } from '../../utils/mcpServerDraftSeed'; import { MCP_SERVER_DRAFT_TEMPLATES } from '../../utils/mcpServerTemplates'; import { validateMCPServerDraft } from '../../utils/mcpServerValidation'; @@ -26,6 +27,25 @@ const normalizeTimeoutSeconds = (value: unknown, fallback: number): number => { return Number.isFinite(parsed) ? parsed : fallback; }; +const redactEnvValues = (env: Record): Record => + Object.fromEntries(Object.keys(env).sort().map((key) => [key, env[key] ? '***' : ''])); + +const buildRedactedFullCommand = ( + fullCommand: string, + parsedCommand: ParseMCPCommandDraftResult | null, +): string => { + if (!fullCommand) { + return ''; + } + if (!parsedCommand?.ok || !parsedCommand.draft) { + return '[解析失败,原始命令已隐藏]'; + } + return [ + ...Object.keys(parsedCommand.draft.env || {}).sort().map((key) => `${key}=***`), + buildMCPLaunchPreview(parsedCommand.draft.command, parsedCommand.draft.args), + ].filter(Boolean).join(' '); +}; + const getTemplateSeed = (templateKey: unknown): Partial => { const normalizedKey = toTrimmedString(templateKey).toLowerCase(); if (!normalizedKey) { @@ -151,12 +171,19 @@ export const buildMCPDraftInspectionSnapshot = (args: Record = const validation = validateMCPServerDraft(server, parsedEnvDraft); const issueKeys = new Set(validation.issues.map((issue) => issue.key)); const recommendedTemplate = resolveRecommendedTemplate(command, commandArgs); + const suggestedServerSeed = buildMCPServerDraftSeed({ + name: toTrimmedString(args.name ?? args.serverName) || undefined, + command, + args: commandArgs, + env, + timeoutSeconds: server.timeoutSeconds, + }); return { input: { hasFullCommand: Boolean(fullCommand), templateKey: toTrimmedString(args.templateKey), - fullCommand, + fullCommand: buildRedactedFullCommand(fullCommand, parsedCommand), }, parse: parsedCommand ? { @@ -184,6 +211,11 @@ export const buildMCPDraftInspectionSnapshot = (args: Record = timeoutSeconds: server.timeoutSeconds, launchCommandPreview: buildMCPLaunchPreview(command, commandArgs), recommendedTemplate, + suggestedServerSeed: { + ...suggestedServerSeed, + env: redactEnvValues(env), + envRedacted: Object.keys(env).length > 0, + }, }, validation: { errorCount: validation.errorCount, diff --git a/frontend/src/components/ai/aiSlashCommands.ts b/frontend/src/components/ai/aiSlashCommands.ts index e63e4f6..b3307d8 100644 --- a/frontend/src/components/ai/aiSlashCommands.ts +++ b/frontend/src/components/ai/aiSlashCommands.ts @@ -50,7 +50,7 @@ export const DEFAULT_AI_SLASH_COMMANDS: AISlashCommandDefinition[] = [ { cmd: '/health', label: '🩺 AI 配置体检', desc: '调用体检探针总览当前 AI 配置', prompt: '请先调用 inspect_ai_setup_health,对当前 GoNavi AI 配置做一次完整体检,然后总结 blockers、warnings 和 nextActions。', category: 'diagnose', featured: true, keywords: ['health', '体检', 'ai配置', '探针'] }, { cmd: '/mcp', label: '🪛 排查 MCP 接入', desc: '检查 MCP 服务和外部客户端状态', prompt: '请先调用 inspect_mcp_setup,帮我盘点当前 MCP 服务、工具发现结果,以及 Claude Code / Codex 本机客户端和 OpenClaw / Hermans 远程 Agent 的接入状态。', category: 'diagnose', featured: true, keywords: ['mcp', 'codex', 'claude', 'openclaw', 'hermans', '外部客户端'] }, { cmd: '/mcpadd', label: '🧭 新增 MCP 指引', desc: '查看 command、args、env 和模板怎么填', prompt: '请先调用 inspect_mcp_authoring_guide;如果我贴了完整启动命令或草稿,再调用 inspect_mcp_draft 试算字段和校验问题;最后结合 inspect_mcp_setup,告诉我新增 GoNavi MCP 服务时 command、args、env、timeout 应该怎么填,以及最接近的模板应该选哪个。', category: 'diagnose', featured: true, keywords: ['mcp新增', 'command', 'args', 'env', '模板'] }, - { cmd: '/mcpdraft', label: '🧪 MCP 草稿校验', desc: '校验一条 MCP 启动命令怎么拆', prompt: '请先调用 inspect_mcp_draft 校验我提供的 MCP fullCommand 或 command/args/env/timeout 草稿,返回自动拆分结果、启动预览、错误/告警和 nextActions;如果还缺字段说明,再补充调用 inspect_mcp_authoring_guide。', category: 'diagnose', keywords: ['mcp草稿', 'mcp校验', 'fullcommand', '启动命令', '参数拆分', 'command', 'args', 'env'] }, + { cmd: '/mcpdraft', label: '🧪 MCP 草稿校验', desc: '校验一条 MCP 启动命令怎么拆', prompt: '请先调用 inspect_mcp_draft 校验我提供的 MCP fullCommand 或 command/args/env/timeout 草稿,返回自动拆分结果、启动预览、suggestedServerSeed、错误/告警和 nextActions;如果还缺字段说明,再补充调用 inspect_mcp_authoring_guide。', category: 'diagnose', keywords: ['mcp草稿', 'mcp校验', 'fullcommand', '启动命令', '参数拆分', 'command', 'args', 'env'] }, { cmd: '/mcptool', label: '🧩 MCP 工具参数', desc: '查看 MCP 工具 schema 和 arguments 写法', prompt: '请先调用 inspect_mcp_setup 找到当前已发现的 MCP 工具 alias;如果我已经给了工具名或关键词,再调用 inspect_mcp_tool_schema 读取对应 inputSchema,告诉我必填参数、字段类型、枚举值、嵌套路径,以及 arguments JSON 应该怎么写。', category: 'diagnose', keywords: ['mcp工具', 'mcp工具参数', 'schema', 'arguments', '参数', '工具调用', 'inputschema'] }, { cmd: '/connfail', label: '🧯 连接失败探针', desc: '总结最近连接失败、冷却和验证异常', prompt: '请先调用 inspect_recent_connection_failures,帮我总结最近数据库连接失败、连接冷却、验证失败和 SSH 隧道异常的真实日志结论;如果已经有明确地址或类型,再结合 inspect_current_connection 或 inspect_saved_connections 继续缩小范围。', category: 'diagnose', featured: true, keywords: ['连接失败', '冷却', '验证失败', 'ssh', 'mysql'] }, { cmd: '/shortcuts', label: '⌨️ 快捷键探针', desc: '读取当前 Win/Mac 快捷键配置', prompt: '请先调用 inspect_shortcuts,告诉我当前 GoNavi 的快捷键配置,尤其是执行 SQL、切换结果区、打开 AI 面板和 AI 发送消息这些动作在当前平台和另一平台分别怎么按,是否改过默认值。', category: 'diagnose', keywords: ['快捷键', 'shortcuts', '结果区', 'mac', 'windows'] }, diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index c7a4f67..b16d677 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.test.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.test.ts @@ -86,7 +86,7 @@ describe('buildAISystemContextMessages', () => { expect(joined).toContain('inspect_ai_tool_catalog 按关键词读取真实工具目录'); expect(joined).toContain('inspect_mcp_setup 读取真实 MCP 配置'); expect(joined).toContain('inspect_mcp_authoring_guide 读取真实新增指引和模板'); - expect(joined).toContain('inspect_mcp_draft 返回自动拆分、启动预览、配置错误/告警和 nextActions'); + expect(joined).toContain('inspect_mcp_draft 返回自动拆分、启动预览、suggestedServerSeed、配置错误/告警和 nextActions'); expect(joined).toContain('inspect_mcp_tool_schema 读取真实 inputSchema'); expect(joined).toContain('inspect_ai_guidance 读取真实提示与技能配置'); expect(joined).toContain('inspect_ai_context 读取当前挂载的表结构上下文'); diff --git a/frontend/src/components/ai/aiSystemInspectionGuidance.ts b/frontend/src/components/ai/aiSystemInspectionGuidance.ts index 0f762ef..e672c6f 100644 --- a/frontend/src/components/ai/aiSystemInspectionGuidance.ts +++ b/frontend/src/components/ai/aiSystemInspectionGuidance.ts @@ -114,7 +114,7 @@ export const appendDatabaseInspectionGuidanceMessages = ( messages, availableToolNames, 'inspect_mcp_draft', - '如果用户贴出 MCP README 启动命令、command/args/env/timeout 草稿,或问“这条 MCP 命令在 GoNavi 里怎么填”,优先调用 inspect_mcp_draft 返回自动拆分、启动预览、配置错误/告警和 nextActions,再给用户具体填写结果。', + '如果用户贴出 MCP README 启动命令、command/args/env/timeout 草稿,或问“这条 MCP 命令在 GoNavi 里怎么填”,优先调用 inspect_mcp_draft 返回自动拆分、启动预览、suggestedServerSeed、配置错误/告警和 nextActions,再给用户具体填写结果。', ); appendGuidanceIfToolAvailable( messages, diff --git a/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts index c4936c7..e741038 100644 --- a/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts @@ -93,14 +93,14 @@ export const BUILTIN_AI_INSPECTION_MCP_TOOL_INFO: AIBuiltinToolInfo[] = [ icon: "🧪", desc: "校验 MCP 新增草稿", detail: - "按完整启动命令或分字段草稿试算 GoNavi 的 MCP 新增配置,返回自动拆分结果、启动预览、字段校验问题、推荐模板和下一步修复建议。适合用户贴出一整行 MCP 启动命令、问 command/args/env/timeout 该怎么拆,或保存前想确认配置有没有明显问题时使用。", + "按完整启动命令或分字段草稿试算 GoNavi 的 MCP 新增配置,返回自动拆分结果、启动预览、可应用草稿、字段校验问题、推荐模板和下一步修复建议。适合用户贴出一整行 MCP 启动命令、问 command/args/env/timeout 该怎么拆,或保存前想确认配置有没有明显问题时使用。", params: "fullCommand?, command?, args?, envText?, timeoutSeconds?, templateKey?, name?", tool: { type: "function", function: { name: "inspect_mcp_draft", description: - "校验一份待新增的 MCP 服务草稿。支持传 fullCommand/rawCommand/commandLine 让 GoNavi 自动拆分,也支持传 command、args、envText、timeoutSeconds 和 templateKey 做分字段校验;返回解析后的字段、启动命令预览、错误/告警、推荐模板和 nextActions。适用于用户贴出 MCP README 启动命令、问新增 MCP 参数怎么填、或 AI 准备指导用户保存前,先用真实校验器试算。", + "校验一份待新增的 MCP 服务草稿。支持传 fullCommand/rawCommand/commandLine 让 GoNavi 自动拆分,也支持传 command、args、envText、timeoutSeconds 和 templateKey 做分字段校验;返回解析后的字段、启动命令预览、suggestedServerSeed、错误/告警、推荐模板和 nextActions。适用于用户贴出 MCP README 启动命令、问新增 MCP 参数怎么填、或 AI 准备指导用户保存前,先用真实校验器试算。", parameters: { type: "object", properties: { diff --git a/frontend/src/utils/mcpServerDraftSeed.test.ts b/frontend/src/utils/mcpServerDraftSeed.test.ts new file mode 100644 index 0000000..aef1ffa --- /dev/null +++ b/frontend/src/utils/mcpServerDraftSeed.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { parseMCPCommandDraft } from './mcpCommandDraft'; +import { buildMCPQuickAddServerSeed, buildMCPServerDraftSeed } from './mcpServerDraftSeed'; + +describe('mcpServerDraftSeed', () => { + it('builds an editable draft seed from a parsed uvx command with env vars', () => { + const parsed = parseMCPCommandDraft('$env:GITHUB_TOKEN=***; uvx mcp-server-github --stdio'); + + expect(parsed.ok).toBe(true); + const seed = buildMCPQuickAddServerSeed(parsed.draft!); + + expect(seed).toMatchObject({ + name: 'mcp-server-github', + transport: 'stdio', + command: 'uvx', + args: ['mcp-server-github', '--stdio'], + env: { GITHUB_TOKEN: '***' }, + enabled: true, + timeoutSeconds: 20, + }); + }); + + it('uses a wider default timeout and image-based name for docker drafts', () => { + const parsed = parseMCPCommandDraft('docker run --rm -i -e API_KEY=*** mcp/server-fetch:latest'); + + expect(parsed.ok).toBe(true); + const seed = buildMCPQuickAddServerSeed(parsed.draft!); + + expect(seed).toMatchObject({ + name: 'server-fetch:latest', + command: 'docker', + args: ['run', '--rm', '-i', '-e', 'API_KEY=***', 'mcp/server-fetch:latest'], + timeoutSeconds: 45, + }); + }); + + it('respects explicit draft names and timeouts for inspection snapshots', () => { + const seed = buildMCPServerDraftSeed({ + name: 'GitHub MCP', + command: 'uvx', + args: ['mcp-server-github', '--stdio'], + timeoutSeconds: 60, + env: { GITHUB_TOKEN: '***' }, + }); + + expect(seed).toMatchObject({ + name: 'GitHub MCP', + command: 'uvx', + timeoutSeconds: 60, + env: { GITHUB_TOKEN: '***' }, + }); + }); +}); diff --git a/frontend/src/utils/mcpServerDraftSeed.ts b/frontend/src/utils/mcpServerDraftSeed.ts new file mode 100644 index 0000000..9a840ff --- /dev/null +++ b/frontend/src/utils/mcpServerDraftSeed.ts @@ -0,0 +1,111 @@ +import type { AIMCPServerConfig } from '../types'; +import type { ParsedMCPCommandDraft } from './mcpCommandDraft'; + +export interface MCPServerDraftSeedInput { + args?: string[]; + command: string; + enabled?: boolean; + env?: Record; + name?: string; + timeoutSeconds?: number; +} + +const stripCommandSuffix = (value: string): string => + value.replace(/\.(exe|cmd|bat|ps1|c?m?[jt]s|py)$/iu, ''); + +const toDisplayNamePart = (value: string): string => { + const text = String(value || '').trim(); + if (!text) return ''; + const lastPathPart = text.split(/[\\/]/u).filter(Boolean).pop() || text; + const packagePart = lastPathPart.includes('/') ? lastPathPart.split('/').filter(Boolean).pop() || lastPathPart : lastPathPart; + return stripCommandSuffix(packagePart).replace(/^@/u, '').trim(); +}; + +const findDockerImageArg = (args: string[]): string => { + const runIndex = args.findIndex((arg) => arg.toLowerCase() === 'run'); + const candidates = runIndex >= 0 ? args.slice(runIndex + 1) : args; + const optionsWithValue = new Set([ + '-e', + '--env', + '--name', + '--network', + '-v', + '--volume', + '-p', + '--publish', + '--entrypoint', + '-w', + '--workdir', + '-u', + '--user', + '--platform', + '-h', + '--hostname', + ]); + + for (let index = 0; index < candidates.length; index += 1) { + const arg = String(candidates[index] || '').trim(); + if (!arg) continue; + if (arg.startsWith('-')) { + if (optionsWithValue.has(arg.toLowerCase())) { + index += 1; + } + continue; + } + if (arg.includes('=') || arg.toLowerCase() === 'run') { + continue; + } + return arg; + } + return ''; +}; + +const pickDraftNameCandidate = (command: string, args: string[]): string => { + const commandName = toDisplayNamePart(command).toLowerCase(); + + if (['npx', 'npm', 'pnpm', 'yarn', 'uvx', 'uv'].includes(commandName)) { + return args.find((arg) => arg && !arg.startsWith('-') && arg.toLowerCase() !== 'stdio') || command; + } + if (['node', 'bun', 'deno'].includes(commandName)) { + return args.find((arg) => arg && !arg.startsWith('-') && arg.toLowerCase() !== 'stdio') || command; + } + if (['python', 'python3', 'py'].includes(commandName)) { + const moduleFlagIndex = args.findIndex((arg) => arg === '-m'); + return (moduleFlagIndex >= 0 ? args[moduleFlagIndex + 1] : '') || args.find((arg) => arg && !arg.startsWith('-')) || command; + } + if (commandName === 'docker') { + return findDockerImageArg(args) || command; + } + return command; +}; + +export const buildMCPServerDraftSeed = ({ + args = [], + command, + enabled = true, + env = {}, + name, + timeoutSeconds, +}: MCPServerDraftSeedInput): Partial => { + const normalizedArgs = args.map((arg) => String(arg || '').trim()).filter(Boolean); + const commandName = toDisplayNamePart(command).toLowerCase(); + const namePart = toDisplayNamePart(name || pickDraftNameCandidate(command, normalizedArgs)) || 'MCP 服务'; + + return { + name: namePart, + transport: 'stdio', + command, + args: normalizedArgs, + env, + enabled, + timeoutSeconds: timeoutSeconds ?? (commandName === 'docker' ? 45 : 20), + }; +}; + +export const buildMCPQuickAddServerSeed = ( + draft: ParsedMCPCommandDraft, +): Partial => buildMCPServerDraftSeed({ + command: draft.command, + args: draft.args, + env: draft.env, +});