feat(mcp): 增强启动命令参数拆分提示

- 识别 command 字段误填整行 MCP 启动命令

- 提供一键拆分 command 和 args 的表单操作

- 补充参数提示工具与组件回归测试
This commit is contained in:
Syngnat
2026-06-12 03:30:17 +08:00
parent 8f0bd61c14
commit 233894f027
5 changed files with 137 additions and 5 deletions

View File

@@ -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(
<AIMCPArgumentHints
command="docker run --rm mcp/server-fetch:latest"
args={['--env', 'API_KEY=secret']}
onArgsChange={onArgsChange}
onCommandArgsChange={onCommandArgsChange}
cardBorder="rgba(0,0,0,0.08)"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
/>,
);
});
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',
]);
});
});

View File

@@ -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<string>();
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<AIMCPArgumentHintsProps> = ({
command,
args,
onArgsChange,
onCommandArgsChange,
cardBorder,
darkMode,
overlayTheme,
@@ -36,7 +52,8 @@ const AIMCPArgumentHints: React.FC<AIMCPArgumentHintsProps> = ({
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 (
<div
@@ -57,6 +74,9 @@ const AIMCPArgumentHints: React.FC<AIMCPArgumentHintsProps> = ({
{profile.title}
</div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>{profile.summary}</div>
{profile.commandFieldWarning ? (
<div style={buildMCPHintStyle('#b45309')}>{profile.commandFieldWarning}</div>
) : null}
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>{profile.orderHint}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{profile.steps.map((step) => (
@@ -107,6 +127,28 @@ const AIMCPArgumentHints: React.FC<AIMCPArgumentHintsProps> = ({
{missingRequiredArgs.join(' / ')}
</button>
) : null}
{canSplitInlineArgs ? (
<button
type="button"
onClick={() => onCommandArgsChange?.(
profile.normalizedCommand,
mergeArgs(profile.inlineArgs, args || []),
)}
style={{
alignSelf: 'flex-start',
padding: '5px 11px',
borderRadius: 999,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(37,99,235,0.16)' : 'rgba(37,99,235,0.10)',
color: '#2563eb',
fontSize: 12,
fontWeight: 700,
cursor: 'pointer',
}}
>
{profile.normalizedCommand} {profile.inlineArgs.length}
</button>
) : null}
</div>
);
};

View File

@@ -135,6 +135,7 @@ const AIMCPServerFormPanel: React.FC<AIMCPServerFormPanelProps> = ({
command={server.command}
args={server.args}
onArgsChange={(args) => onChange({ args })}
onCommandArgsChange={(command, args) => onChange({ command, args })}
cardBorder={cardBorder}
darkMode={darkMode}
overlayTheme={overlayTheme}

View File

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

View File

@@ -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 只填 dockerrun、-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 -> 配置文件或业务参数',