mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-03 16:21:24 +08:00
✨ feat(ai): 增强 MCP 草稿校验输出
- 抽取 MCP 草稿 seed 构建逻辑供 UI 和内置工具复用 - inspect_mcp_draft 返回脱敏 suggestedServerSeed - 同步 slash 命令、系统指导和回归测试
This commit is contained in:
@@ -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: {
|
||||
|
||||
54
frontend/src/utils/mcpServerDraftSeed.test.ts
Normal file
54
frontend/src/utils/mcpServerDraftSeed.test.ts
Normal 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: '***' },
|
||||
});
|
||||
});
|
||||
});
|
||||
111
frontend/src/utils/mcpServerDraftSeed.ts
Normal file
111
frontend/src/utils/mcpServerDraftSeed.ts
Normal 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,
|
||||
});
|
||||
Reference in New Issue
Block a user