mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-17 03:59:41 +08:00
✨ feat(mcp): 增强启动命令参数拆分提示
- 识别 command 字段误填整行 MCP 启动命令 - 提供一键拆分 command 和 args 的表单操作 - 补充参数提示工具与组件回归测试
This commit is contained in:
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', []);
|
||||
|
||||
|
||||
@@ -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 -> 配置文件或业务参数',
|
||||
|
||||
Reference in New Issue
Block a user