diff --git a/frontend/src/components/ai/AIMCPArgumentHints.test.tsx b/frontend/src/components/ai/AIMCPArgumentHints.test.tsx index 253cfae..1435bbf 100644 --- a/frontend/src/components/ai/AIMCPArgumentHints.test.tsx +++ b/frontend/src/components/ai/AIMCPArgumentHints.test.tsx @@ -49,4 +49,47 @@ describe('AIMCPArgumentHints', () => { expect(onArgsChange).toHaveBeenCalledWith(['run', '--rm', '-i', 'mcp/server-fetch:latest']); }); + + it('can split a full command line pasted into the command field', async () => { + const onArgsChange = vi.fn(); + const onCommandArgsChange = vi.fn(); + let renderer!: ReactTestRenderer; + + await act(async () => { + renderer = create( + , + ); + }); + + const text = flattenRendererText(renderer.toJSON()); + expect(text).toContain('启动命令字段里还包含 3 个参数'); + expect(text).not.toContain('一键补齐缺失必填参数'); + + const buttons = renderer.root.findAll( + (node) => node.type === 'button' && flattenRendererText(node).includes('一键拆分启动命令字段'), + ); + + expect(buttons.length).toBe(1); + + await act(async () => { + buttons[0].props.onClick(); + }); + + expect(onArgsChange).not.toHaveBeenCalled(); + expect(onCommandArgsChange).toHaveBeenCalledWith('docker', [ + 'run', + '--rm', + 'mcp/server-fetch:latest', + '--env', + 'API_KEY=secret', + ]); + }); }); diff --git a/frontend/src/components/ai/AIMCPArgumentHints.tsx b/frontend/src/components/ai/AIMCPArgumentHints.tsx index fbecbae..14a91d7 100644 --- a/frontend/src/components/ai/AIMCPArgumentHints.tsx +++ b/frontend/src/components/ai/AIMCPArgumentHints.tsx @@ -9,6 +9,7 @@ interface AIMCPArgumentHintsProps { command: string; args?: string[]; onArgsChange?: (args: string[]) => void; + onCommandArgsChange?: (command: string, args: string[]) => void; cardBorder: string; darkMode: boolean; overlayTheme: OverlayWorkbenchTheme; @@ -23,10 +24,25 @@ const buildMissingRequiredArgs = ( .map((item) => item.trim()) .filter(Boolean); +const mergeArgs = (left: string[], right: string[]): string[] => { + const seen = new Set(); + const result: string[] = []; + for (const item of [...left, ...right]) { + const text = String(item || '').trim(); + if (!text) continue; + const key = text.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(text); + } + return result; +}; + const AIMCPArgumentHints: React.FC = ({ command, args, onArgsChange, + onCommandArgsChange, cardBorder, darkMode, overlayTheme, @@ -36,7 +52,8 @@ const AIMCPArgumentHints: React.FC = ({ return null; } const missingRequiredArgs = buildMissingRequiredArgs(profile); - const canApplyMissingArgs = Boolean(onArgsChange && missingRequiredArgs.length > 0); + const canApplyMissingArgs = Boolean(onArgsChange && missingRequiredArgs.length > 0 && profile.inlineArgs.length === 0); + const canSplitInlineArgs = Boolean(onCommandArgsChange && profile.inlineArgs.length > 0); return (
= ({ {profile.title}
{profile.summary}
+ {profile.commandFieldWarning ? ( +
{profile.commandFieldWarning}
+ ) : null}
{profile.orderHint}
{profile.steps.map((step) => ( @@ -107,6 +127,28 @@ const AIMCPArgumentHints: React.FC = ({ 一键补齐缺失必填参数:{missingRequiredArgs.join(' / ')} ) : null} + {canSplitInlineArgs ? ( + + ) : null}
); }; diff --git a/frontend/src/components/ai/AIMCPServerFormPanel.tsx b/frontend/src/components/ai/AIMCPServerFormPanel.tsx index d45c19b..06b2bc8 100644 --- a/frontend/src/components/ai/AIMCPServerFormPanel.tsx +++ b/frontend/src/components/ai/AIMCPServerFormPanel.tsx @@ -135,6 +135,7 @@ const AIMCPServerFormPanel: React.FC = ({ command={server.command} args={server.args} onArgsChange={(args) => onChange({ args })} + onCommandArgsChange={(command, args) => onChange({ command, args })} cardBorder={cardBorder} darkMode={darkMode} overlayTheme={overlayTheme} diff --git a/frontend/src/utils/mcpArgumentHints.test.ts b/frontend/src/utils/mcpArgumentHints.test.ts index 29f3e82..0cc64e6 100644 --- a/frontend/src/utils/mcpArgumentHints.test.ts +++ b/frontend/src/utils/mcpArgumentHints.test.ts @@ -37,6 +37,17 @@ describe('mcpArgumentHints', () => { expect(profile?.nextActions).toContain('补充 镜像名,示例:mcp/server-fetch:latest'); }); + it('detects full command lines pasted into the command field', () => { + const profile = buildMCPArgumentHintProfile('docker run --rm mcp/server-fetch:latest', []); + + expect(profile?.normalizedCommand).toBe('docker'); + expect(profile?.inlineArgs).toEqual(['run', '--rm', 'mcp/server-fetch:latest']); + expect(profile?.commandFieldWarning).toContain('启动命令字段里还包含 3 个参数'); + expect(profile?.steps.find((item) => item.key === 'run')?.satisfied).toBe(true); + expect(profile?.steps.find((item) => item.key === 'image')?.satisfied).toBe(true); + expect(profile?.nextActions).toContain('补充 保持标准输入,示例:-i'); + }); + it('falls back to executable guidance for custom binaries', () => { const profile = buildMCPArgumentHintProfile('D:\\tools\\acme-mcp-server.exe', []); diff --git a/frontend/src/utils/mcpArgumentHints.ts b/frontend/src/utils/mcpArgumentHints.ts index 57876c9..f192d75 100644 --- a/frontend/src/utils/mcpArgumentHints.ts +++ b/frontend/src/utils/mcpArgumentHints.ts @@ -11,6 +11,9 @@ export interface MCPArgumentHintStep { export interface MCPArgumentHintProfile { commandName: string; + normalizedCommand: string; + inlineArgs: string[]; + commandFieldWarning?: string; title: string; summary: string; orderHint: string; @@ -20,15 +23,26 @@ export interface MCPArgumentHintProfile { const toTrimmedString = (value: unknown): string => String(value ?? '').trim(); -const normalizeCommandName = (command: string): string => { +const parseCommandField = (command: string): { normalizedCommand: string; commandName: string; inlineArgs: string[] } => { const { tokens } = splitShellLikeCommand(command); const raw = toTrimmedString(tokens[0] || command); const lastPathPart = raw.split(/[\\/]/u).pop() || raw; - return lastPathPart + const commandName = lastPathPart .replace(/\.(exe|cmd|bat|ps1)$/iu, '') .toLowerCase(); + const inlineArgs = tokens.length > 1 && isInlineArgHintCommand(commandName) + ? tokens.slice(1).map(toTrimmedString).filter(Boolean) + : []; + return { + normalizedCommand: raw, + commandName, + inlineArgs, + }; }; +const isInlineArgHintCommand = (commandName: string): boolean => + ['npx', 'npm', 'pnpm', 'yarn', 'node', 'bun', 'deno', 'python', 'python3', 'py', 'uvx', 'uv', 'docker'].includes(commandName); + const normalizeArgs = (args?: string[]): string[] => (Array.isArray(args) ? args : []).map(toTrimmedString).filter(Boolean); @@ -120,11 +134,14 @@ export const buildMCPArgumentHintProfile = ( command: string, args?: string[], ): MCPArgumentHintProfile | null => { - const commandName = normalizeCommandName(command); + const { normalizedCommand, commandName, inlineArgs } = parseCommandField(command); if (!commandName) { return null; } - const normalizedArgs = normalizeArgs(args); + const normalizedArgs = [...inlineArgs, ...normalizeArgs(args)]; + const commandFieldWarning = inlineArgs.length > 0 + ? `检测到启动命令字段里还包含 ${inlineArgs.length} 个参数:${inlineArgs.join(' / ')}。建议 command 只保留 ${normalizedCommand},其余移到命令参数。` + : undefined; if (commandName === 'npx' || commandName === 'npm' || commandName === 'pnpm' || commandName === 'yarn') { const steps = [ @@ -135,6 +152,9 @@ export const buildMCPArgumentHintProfile = ( ]; return { commandName, + normalizedCommand, + inlineArgs, + commandFieldWarning, title: 'npx / npm 参数顺序建议', summary: 'npm 生态 MCP 通常要把安装确认、包名和 --stdio 拆成独立参数标签。', orderHint: '推荐顺序:-y -> 包名 -> --stdio -> 服务自己的业务参数', @@ -151,6 +171,9 @@ export const buildMCPArgumentHintProfile = ( ]; return { commandName, + normalizedCommand, + inlineArgs, + commandFieldWarning, title: 'Node 脚本参数顺序建议', summary: 'Node 类启动器的命令只填 node/bun/deno,脚本路径和 --stdio 放到参数里。', orderHint: '推荐顺序:脚本路径 -> --stdio -> 服务自己的业务参数', @@ -167,6 +190,9 @@ export const buildMCPArgumentHintProfile = ( ]; return { commandName, + normalizedCommand, + inlineArgs, + commandFieldWarning, title: 'Python 参数顺序建议', summary: 'Python MCP 常见形式是 python -m 模块名,-m 和模块名都要作为独立参数。', orderHint: '推荐顺序:-m -> 模块名 -> --stdio', @@ -183,6 +209,9 @@ export const buildMCPArgumentHintProfile = ( ]; return { commandName, + normalizedCommand, + inlineArgs, + commandFieldWarning, title: 'uvx 参数顺序建议', summary: 'uvx 类 MCP 通常把包名作为第一个参数,再按 README 补 stdio 或配置参数。', orderHint: '推荐顺序:包名 -> --stdio -> 服务自己的业务参数', @@ -201,6 +230,9 @@ export const buildMCPArgumentHintProfile = ( ]; return { commandName, + normalizedCommand, + inlineArgs, + commandFieldWarning, title: 'Docker MCP 参数顺序建议', summary: 'Docker 场景 command 只填 docker,run、-i、--rm、镜像名和容器参数都放到 args 里。', orderHint: '推荐顺序:run -> --rm -> -i -> -e KEY=VALUE -> 镜像名 -> 服务自己的业务参数', @@ -215,6 +247,9 @@ export const buildMCPArgumentHintProfile = ( ]; return { commandName, + normalizedCommand, + inlineArgs, + commandFieldWarning, title: '本机可执行文件参数建议', summary: '自研或已编译 MCP Server 的参数以 README 为准;GoNavi 会原样按标签顺序传入。', orderHint: '常见顺序:stdio/--stdio -> 配置文件或业务参数',