feat(mcp): 增强新增服务参数逐项提示

- 新增 MCP 参数逐项说明,覆盖未知参数、位置参数和常见运行时参数

- 对敏感参数值做脱敏展示,避免提示区泄露 token 或 api key

- 将逐项说明拆分到独立 util,并接入 inspect_mcp_draft 诊断输出
This commit is contained in:
Syngnat
2026-06-12 09:09:47 +08:00
parent fce50b513c
commit b815e7b296
7 changed files with 439 additions and 8 deletions

View File

@@ -115,12 +115,40 @@ describe('AIMCPArgumentHints', () => {
});
const text = flattenRendererText(renderer.toJSON());
expect(text).toContain('参数逐项说明');
expect(text).toContain('已识别业务参数');
expect(text).toContain('--api-key');
expect(text).toContain('API Key');
expect(text).toContain('不要截图真实值');
expect(text).toContain('--directory');
expect(text).toContain('授权目录');
expect(text).toContain('值已脱敏');
expect(text).not.toContain('sk-real-secret');
});
it('renders fallback explanations for unknown MCP args', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<AIMCPArgumentHints
command="acme-mcp-server"
args={['--tenant', 'prod', 'target-a']}
cardBorder="rgba(0,0,0,0.08)"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
/>,
);
});
const text = flattenRendererText(renderer.toJSON());
expect(text).toContain('参数逐项说明');
expect(text).toContain('--tenant');
expect(text).toContain('未识别参数');
expect(text).toContain('GoNavi 不能从参数名 --tenant 准确判断业务含义');
expect(text).toContain('prod');
expect(text).toContain('未识别参数的值');
expect(text).toContain('target-a');
expect(text).toContain('位置参数');
});
});

View File

@@ -1,6 +1,7 @@
import React from 'react';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import { buildMCPArgumentDetailHints } from '../../utils/mcpArgumentDetailHints';
import { buildMCPArgumentHintProfile } from '../../utils/mcpArgumentHints';
import { splitShellLikeCommand } from '../../utils/mcpCommandDraft';
import { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock';
@@ -72,6 +73,10 @@ const AIMCPArgumentHints: React.FC<AIMCPArgumentHintsProps> = ({
return null;
}
const missingRequiredArgs = buildMissingRequiredArgs(profile);
const argumentHints = buildMCPArgumentDetailHints(profile.commandName, [
...profile.inlineArgs,
...(args || []),
]);
const canApplyMissingArgs = Boolean(onArgsChange && missingRequiredArgs.length > 0 && profile.inlineArgs.length === 0);
const canSplitInlineArgs = Boolean(onCommandArgsChange && profile.inlineArgs.length > 0);
@@ -119,6 +124,53 @@ const AIMCPArgumentHints: React.FC<AIMCPArgumentHintsProps> = ({
</span>
))}
</div>
{argumentHints.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 8 }}>
{argumentHints.map((hint) => (
<div
key={hint.key}
style={{
padding: '8px 10px',
borderRadius: 10,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.82)',
display: 'flex',
flexDirection: 'column',
gap: 5,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<code style={{ fontFamily: 'var(--gn-font-mono)', fontSize: 12, color: overlayTheme.titleText }}>
{hint.argument}
</code>
<span
style={{
padding: '1px 7px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: businessHintCategoryColor[hint.category],
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)',
}}
>
{businessHintCategoryLabel[hint.category]}
</span>
{hint.sensitive ? <span style={buildMCPHintStyle('#b45309')}></span> : null}
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}>{hint.label}</div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>{hint.detail}</div>
<div style={buildMCPHintStyle(hint.sensitive ? '#b45309' : overlayTheme.mutedText)}>
{hint.valueHint}
</div>
</div>
))}
</div>
</div>
) : null}
{profile.businessHints.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}>

View File

@@ -412,12 +412,14 @@ describe('aiLocalToolExecutor AI config inspection tools', () => {
expect(result.success).toBe(true);
expect(result.content).toContain('"argumentHints"');
expect(result.content).toContain('"argumentDetailHints"');
expect(result.content).toContain('"businessHints"');
expect(result.content).toContain('"argument":"--api-key"');
expect(result.content).toContain('"label":"API Key"');
expect(result.content).toContain('"sensitive":true');
expect(result.content).toContain('"argument":"--directory"');
expect(result.content).toContain('"label":"授权目录"');
expect(result.content).toContain('"label":"授权目录的值"');
expect(result.content).toContain('"argsRedacted":true');
expect(result.content).toContain('"--api-key=***"');
expect(result.content).not.toContain('sk-real-secret');

View File

@@ -1,4 +1,5 @@
import type { AIMCPServerConfig } from '../../types';
import { buildMCPArgumentDetailHints } from '../../utils/mcpArgumentDetailHints';
import { buildMCPArgumentHintProfile } from '../../utils/mcpArgumentHints';
import { parseMCPCommandDraft, type ParseMCPCommandDraftResult } from '../../utils/mcpCommandDraft';
import { buildMCPEnvHintProfile } from '../../utils/mcpEnvHints';
@@ -254,6 +255,7 @@ export const buildMCPDraftInspectionSnapshot = (args: Record<string, unknown> =
summary: argumentHintProfile.summary,
orderHint: argumentHintProfile.orderHint,
steps: argumentHintProfile.steps,
argumentDetailHints: buildMCPArgumentDetailHints(argumentHintProfile.commandName, commandArgs),
businessHints: argumentHintProfile.businessHints,
nextActions: argumentHintProfile.nextActions,
} : null,

View File

@@ -0,0 +1,281 @@
import {
type BusinessArgumentHintTemplate,
type MCPBusinessArgumentHintCategory,
hasDockerImageArg,
hasPackageLikeArg,
normalizeFlagName,
resolveBusinessArgumentHintTemplate,
sanitizeFlagForDisplay,
toTrimmedString,
} from './mcpArgumentHints';
export interface MCPArgumentDetailHint {
key: string;
argument: string;
category: MCPBusinessArgumentHintCategory;
label: string;
detail: string;
valueHint: string;
sensitive: boolean;
}
const VALUE_ARG_FLAGS = new Set([
'api-key',
'token',
'access-token',
'password',
'secret',
'config',
'config-file',
'c',
'directory',
'dir',
'root',
'workspace',
'path',
'url',
'endpoint',
'base-url',
'host',
'port',
'transport',
'mode',
'profile',
'tenant',
'project',
'account',
'executable-path',
'repo',
'e',
'env',
'name',
'network',
'v',
'volume',
'p',
'publish',
'entrypoint',
'w',
'workdir',
'u',
'user',
'platform',
'h',
'hostname',
]);
const flagExpectsValue = (flag: string): boolean => VALUE_ARG_FLAGS.has(flag);
const fallbackArgumentHint = (flag: string): BusinessArgumentHintTemplate => ({
category: 'generic',
label: '未识别参数',
detail: `GoNavi 不能从参数名 --${flag} 准确判断业务含义,但会按当前顺序原样传给 MCP 进程。`,
valueHint: '请对照 MCP README 确认这个参数是否需要值;需要值时把值作为下一个参数标签,或使用 --name=value。',
sensitive: false,
});
const sanitizeArgumentValueForDisplay = (value: string, sensitive = false): string => {
const text = toTrimmedString(value);
if (!text) return '';
if (sensitive) return '<已隐藏>';
if (/^(.{0,24})=(.*)$/u.test(text) && /(token|api[-_]?key|secret|password|credential)/iu.test(text.split('=')[0])) {
return `${text.split('=')[0]}=<已隐藏>`;
}
if (/(sk-[a-z0-9_-]{8,}|ghp_[a-z0-9_]{8,}|xox[baprs]-[a-z0-9-]{8,})/iu.test(text)) {
return '<疑似密钥,已隐藏>';
}
return text;
};
const buildArgumentDetail = (
key: string,
argument: string,
template: BusinessArgumentHintTemplate,
): MCPArgumentDetailHint => ({
key,
argument,
category: template.category,
label: template.label,
detail: template.detail,
valueHint: template.valueHint,
sensitive: template.sensitive,
});
const runtimeArgumentTemplate = (
commandName: string,
args: string[],
arg: string,
index: number,
): BusinessArgumentHintTemplate | null => {
const text = toTrimmedString(arg);
const lower = text.toLowerCase();
if (lower === '--stdio' || lower === 'stdio') {
return {
category: 'mode',
label: 'stdio 通信模式',
detail: '让 MCP Server 通过标准输入输出和 GoNavi 保持通信。',
valueHint: '这是开关参数,一般不需要额外值。',
sensitive: false,
};
}
if (lower === '-y' && ['npx', 'npm', 'pnpm', 'yarn'].includes(commandName)) {
return {
category: 'runtime',
label: '跳过安装确认',
detail: '避免 npx 首次启动包时等待交互确认,适合后台工具发现。',
valueHint: '这是开关参数,不需要额外值。',
sensitive: false,
};
}
if (lower === '-m' && ['python', 'python3', 'py'].includes(commandName)) {
return {
category: 'runtime',
label: 'Python 模块启动',
detail: '表示后一个参数是 Python 模块名,而不是脚本文件路径。',
valueHint: '后面补模块名,例如 your_mcp_server。',
sensitive: false,
};
}
if (commandName === 'docker') {
if (lower === 'run') {
return {
category: 'runtime',
label: 'Docker 运行子命令',
detail: '表示启动一个容器来运行 MCP Server。',
valueHint: '通常放在 docker 后面的第一个参数。',
sensitive: false,
};
}
if (lower === '-i' || lower === '--interactive') {
return {
category: 'runtime',
label: '保持标准输入',
detail: 'MCP stdio 需要容器 stdin 持续打开,否则工具发现可能启动后立刻断开。',
valueHint: '这是 Docker MCP 的关键参数。',
sensitive: false,
};
}
if (lower === '--rm') {
return {
category: 'runtime',
label: '退出后清理容器',
detail: '测试和日常使用后自动删除临时容器,避免残留。',
valueHint: '这是开关参数,不需要额外值。',
sensitive: false,
};
}
if (!text.startsWith('-') && hasDockerImageArg(args.slice(0, index + 1))) {
return {
category: 'runtime',
label: 'Docker 镜像或容器参数',
detail: '这是 docker run 中的镜像名或传给容器内 MCP 服务的位置参数。',
valueHint: '镜像名应来自 MCP README镜像后的参数会传给容器入口程序。',
sensitive: false,
};
}
}
if (!text.startsWith('-')) {
if (['npx', 'npm', 'pnpm', 'yarn'].includes(commandName) && hasPackageLikeArg([text])) {
return {
category: 'runtime',
label: 'MCP 包名或位置参数',
detail: '通常是 README 里的 npm 包名,也可能是包自己的业务参数。',
valueHint: '包名一般放在 -y 后、--stdio 前;业务参数以 README 为准。',
sensitive: false,
};
}
if (commandName === 'uvx' || commandName === 'uv') {
return {
category: 'runtime',
label: 'Python MCP 包名或位置参数',
detail: 'uvx 后面通常跟 MCP 包名;后续位置参数会传给该 MCP 服务。',
valueHint: '第一个位置参数应是 README 里的包名。',
sensitive: false,
};
}
if (['node', 'bun', 'deno'].includes(commandName)) {
return {
category: /\.(c?m?[jt]s)$/iu.test(text) || /[\\/]/u.test(text) ? 'path' : 'runtime',
label: '脚本或位置参数',
detail: '通常是本地 MCP Server 的入口脚本;脚本后的值会作为业务参数传入。',
valueHint: '入口脚本建议使用本机可访问的相对或绝对路径。',
sensitive: false,
};
}
if (['python', 'python3', 'py'].includes(commandName)) {
return {
category: args[index - 1] === '-m' ? 'runtime' : 'path',
label: args[index - 1] === '-m' ? 'Python 模块名' : 'Python 脚本或位置参数',
detail: args[index - 1] === '-m'
? '这是 -m 后面的模块名,不要带 .py 后缀。'
: '通常是本地 Python MCP 脚本路径,或传给脚本的位置参数。',
valueHint: '以 README 的启动示例为准。',
sensitive: false,
};
}
}
return null;
};
export const buildMCPArgumentDetailHints = (commandName: string, args: string[]): MCPArgumentDetailHint[] => {
const result: MCPArgumentDetailHint[] = [];
for (let index = 0; index < args.length; index += 1) {
const text = toTrimmedString(args[index]);
if (!text) continue;
const previousFlag = index > 0 ? normalizeFlagName(args[index - 1]) : '';
const previousHasInlineValue = index > 0 && toTrimmedString(args[index - 1]).includes('=');
if (previousFlag && !previousHasInlineValue && flagExpectsValue(previousFlag) && !text.startsWith('-')) {
const template = resolveBusinessArgumentHintTemplate(previousFlag, true) || fallbackArgumentHint(previousFlag);
result.push(buildArgumentDetail(
`value-${index}-${previousFlag}`,
sanitizeArgumentValueForDisplay(text, template.sensitive),
{
...template,
label: `${template.label}的值`,
detail: template.sensitive
? `这是前一个 ${sanitizeFlagForDisplay(args[index - 1])} 的敏感值,提示中已脱敏。`
: `这是前一个 ${sanitizeFlagForDisplay(args[index - 1])} 参数的值。`,
},
));
continue;
}
const runtimeTemplate = runtimeArgumentTemplate(commandName, args, text, index);
if (runtimeTemplate) {
result.push(buildArgumentDetail(
`runtime-${index}-${text}`,
sanitizeArgumentValueForDisplay(text, runtimeTemplate.sensitive),
runtimeTemplate,
));
continue;
}
const flag = normalizeFlagName(text);
if (flag) {
const template = resolveBusinessArgumentHintTemplate(flag, true) || fallbackArgumentHint(flag);
result.push(buildArgumentDetail(
`flag-${index}-${flag}`,
sanitizeFlagForDisplay(text),
template,
));
continue;
}
result.push(buildArgumentDetail(
`positional-${index}`,
sanitizeArgumentValueForDisplay(text),
{
category: 'generic',
label: '位置参数',
detail: '这是没有参数名的位置参数GoNavi 会按当前顺序原样传入 MCP 进程。',
valueHint: '请对照 README 判断它是包名、路径、镜像名还是业务参数。',
sensitive: false,
},
));
}
return result;
};

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildMCPArgumentDetailHints } from './mcpArgumentDetailHints';
import { buildMCPArgumentHintProfile } from './mcpArgumentHints';
describe('mcpArgumentHints', () => {
@@ -86,13 +87,54 @@ describe('mcpArgumentHints', () => {
]));
});
it('builds per-argument explanations for unknown flags and positional values', () => {
const hints = buildMCPArgumentDetailHints('acme-mcp-server', [
'--tenant',
'prod',
'--workspace',
'D:\\Work',
'extra-target',
]);
expect(hints).toEqual(expect.arrayContaining([
expect.objectContaining({
argument: '--tenant',
label: '未识别参数',
category: 'generic',
}),
expect.objectContaining({
argument: 'prod',
label: '未识别参数的值',
}),
expect.objectContaining({
argument: '--workspace',
label: '工作区目录',
category: 'path',
}),
expect.objectContaining({
argument: 'D:\\Work',
label: '工作区目录的值',
}),
expect.objectContaining({
argument: 'extra-target',
label: '位置参数',
}),
]));
});
it('sanitizes sensitive inline argument values in hints', () => {
const profile = buildMCPArgumentHintProfile('uvx', [
const args = [
'mcp-server-demo',
'--api-key=sk-real-secret',
'--token',
'ghp_real-secret-token',
'--endpoint',
'https://api.example.com',
];
const profile = buildMCPArgumentHintProfile('uvx', [
...args,
]);
const argumentHints = buildMCPArgumentDetailHints('uvx', args);
expect(profile?.businessHints).toEqual(expect.arrayContaining([
expect.objectContaining({
@@ -107,5 +149,18 @@ describe('mcpArgumentHints', () => {
}),
]));
expect(JSON.stringify(profile?.businessHints)).not.toContain('sk-real-secret');
expect(argumentHints).toEqual(expect.arrayContaining([
expect.objectContaining({
argument: '--api-key',
sensitive: true,
}),
expect.objectContaining({
argument: '<已隐藏>',
label: 'Token的值',
sensitive: true,
}),
]));
expect(JSON.stringify(argumentHints)).not.toContain('sk-real-secret');
expect(JSON.stringify(argumentHints)).not.toContain('ghp_real-secret-token');
});
});

View File

@@ -34,7 +34,7 @@ export interface MCPArgumentHintProfile {
nextActions: string[];
}
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
export const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const parseCommandField = (command: string): { normalizedCommand: string; commandName: string; inlineArgs: string[] } => {
const { tokens } = splitShellLikeCommand(command);
@@ -65,7 +65,7 @@ const hasArg = (args: string[], expected: string): boolean =>
const hasStdioArg = (args: string[]): boolean =>
hasArg(args, '--stdio') || hasArg(args, 'stdio');
const hasPackageLikeArg = (args: string[]): boolean =>
export const hasPackageLikeArg = (args: string[]): boolean =>
args.some((arg) => {
const text = arg.trim();
if (!text || text.startsWith('-')) return false;
@@ -86,7 +86,7 @@ const hasDockerRunArg = (args: string[]): boolean =>
const hasDockerInteractiveArg = (args: string[]): boolean =>
hasArg(args, '-i') || hasArg(args, '--interactive');
const hasDockerImageArg = (args: string[]): boolean => {
export const hasDockerImageArg = (args: string[]): boolean => {
const runIndex = args.findIndex((arg) => arg.toLowerCase() === 'run');
const candidates = runIndex >= 0 ? args.slice(runIndex + 1) : args;
for (let index = 0; index < candidates.length; index += 1) {
@@ -143,7 +143,7 @@ const buildNextActions = (steps: MCPArgumentHintStep[]): string[] =>
.filter((step) => step.required && !step.satisfied)
.map((step) => `补充 ${step.label},示例:${step.example}`);
type BusinessArgumentHintTemplate = Omit<MCPBusinessArgumentHint, 'key' | 'argument'>;
export type BusinessArgumentHintTemplate = Omit<MCPBusinessArgumentHint, 'key' | 'argument'>;
const BUSINESS_ARGUMENT_HINTS: Record<string, BusinessArgumentHintTemplate> = {
'api-key': {
@@ -330,7 +330,7 @@ const BUSINESS_ARGUMENT_HINTS: Record<string, BusinessArgumentHintTemplate> = {
},
};
const normalizeFlagName = (arg: string): string => {
export const normalizeFlagName = (arg: string): string => {
const text = toTrimmedString(arg);
if (!text.startsWith('-') || text === '-' || text === '--') {
return '';
@@ -339,7 +339,7 @@ const normalizeFlagName = (arg: string): string => {
return withoutValue.replace(/^-+/u, '').trim().toLowerCase();
};
const sanitizeFlagForDisplay = (arg: string): string => {
export const sanitizeFlagForDisplay = (arg: string): string => {
const text = toTrimmedString(arg);
const withoutValue = text.split('=')[0];
return withoutValue || text;
@@ -383,6 +383,17 @@ const inferBusinessArgumentHint = (flag: string): BusinessArgumentHintTemplate |
return null;
};
const buildGenericArgumentHint = (flag: string): BusinessArgumentHintTemplate => ({
category: 'generic',
label: '未识别参数',
detail: `GoNavi 不能从参数名 --${flag} 准确判断业务含义,但会按当前顺序原样传给 MCP 进程。`,
valueHint: '请对照 MCP README 确认这个参数是否需要值;需要值时把值作为下一个参数标签,或使用 --name=value。',
sensitive: false,
});
export const resolveBusinessArgumentHintTemplate = (flag: string, fallbackGeneric = false): BusinessArgumentHintTemplate | null =>
BUSINESS_ARGUMENT_HINTS[flag] || inferBusinessArgumentHint(flag) || (fallbackGeneric && flag ? buildGenericArgumentHint(flag) : null);
const buildBusinessArgumentHints = (args: string[]): MCPBusinessArgumentHint[] => {
const result: MCPBusinessArgumentHint[] = [];
const seen = new Set<string>();
@@ -391,7 +402,7 @@ const buildBusinessArgumentHints = (args: string[]): MCPBusinessArgumentHint[] =
if (!flag || flag === 'stdio') {
continue;
}
const template = BUSINESS_ARGUMENT_HINTS[flag] || inferBusinessArgumentHint(flag);
const template = resolveBusinessArgumentHintTemplate(flag);
if (!template) {
continue;
}