feat(ai): 增强 MCP 草稿校验输出

- 抽取 MCP 草稿 seed 构建逻辑供 UI 和内置工具复用

- inspect_mcp_draft 返回脱敏 suggestedServerSeed

- 同步 slash 命令、系统指导和回归测试
This commit is contained in:
Syngnat
2026-06-11 20:53:43 +08:00
parent ca1c8559cf
commit 9038fe1bdf
11 changed files with 228 additions and 129 deletions

View File

@@ -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,
});
});
});

View File

@@ -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<AIMCPServerConfig>) => 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<AIMCPServerConfig> => {
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,

View File

@@ -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');
});

View File

@@ -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');

View File

@@ -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<string, string>): Record<string, string> =>
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<AIMCPServerConfig> => {
const normalizedKey = toTrimmedString(templateKey).toLowerCase();
if (!normalizedKey) {
@@ -151,12 +171,19 @@ export const buildMCPDraftInspectionSnapshot = (args: Record<string, unknown> =
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<string, unknown> =
timeoutSeconds: server.timeoutSeconds,
launchCommandPreview: buildMCPLaunchPreview(command, commandArgs),
recommendedTemplate,
suggestedServerSeed: {
...suggestedServerSeed,
env: redactEnvValues(env),
envRedacted: Object.keys(env).length > 0,
},
},
validation: {
errorCount: validation.errorCount,

View File

@@ -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'] },

View File

@@ -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 读取当前挂载的表结构上下文');

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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: '***' },
});
});
});

View File

@@ -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<string, string>;
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<AIMCPServerConfig> => {
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<AIMCPServerConfig> => buildMCPServerDraftSeed({
command: draft.command,
args: draft.args,
env: draft.env,
});