Files
MyGoNavi/frontend/src/utils/mcpArgumentHints.ts
Syngnat b815e7b296 feat(mcp): 增强新增服务参数逐项提示
- 新增 MCP 参数逐项说明,覆盖未知参数、位置参数和常见运行时参数

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

- 将逐项说明拆分到独立 util,并接入 inspect_mcp_draft 诊断输出
2026-06-12 09:09:47 +08:00

556 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { splitShellLikeCommand } from './mcpCommandDraft';
export interface MCPArgumentHintStep {
key: string;
label: string;
example: string;
detail: string;
required: boolean;
satisfied: boolean;
}
export type MCPBusinessArgumentHintCategory = 'secret' | 'path' | 'endpoint' | 'network' | 'mode' | 'runtime' | 'generic';
export interface MCPBusinessArgumentHint {
key: string;
argument: string;
category: MCPBusinessArgumentHintCategory;
label: string;
detail: string;
valueHint: string;
sensitive: boolean;
}
export interface MCPArgumentHintProfile {
commandName: string;
normalizedCommand: string;
inlineArgs: string[];
commandFieldWarning?: string;
title: string;
summary: string;
orderHint: string;
steps: MCPArgumentHintStep[];
businessHints: MCPBusinessArgumentHint[];
nextActions: string[];
}
export const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
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;
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);
const hasArg = (args: string[], expected: string): boolean =>
args.some((arg) => arg.toLowerCase() === expected.toLowerCase());
const hasStdioArg = (args: string[]): boolean =>
hasArg(args, '--stdio') || hasArg(args, 'stdio');
export const hasPackageLikeArg = (args: string[]): boolean =>
args.some((arg) => {
const text = arg.trim();
if (!text || text.startsWith('-')) return false;
return !['stdio'].includes(text.toLowerCase());
});
const hasScriptLikeArg = (args: string[]): boolean =>
args.some((arg) => /\.(c?m?[jt]s|py)$/iu.test(arg) || /[\\/]/u.test(arg));
const hasPythonModuleArg = (args: string[]): boolean => {
const moduleFlagIndex = args.findIndex((arg) => arg === '-m');
return moduleFlagIndex >= 0 && Boolean(args[moduleFlagIndex + 1]);
};
const hasDockerRunArg = (args: string[]): boolean =>
args.some((arg) => arg.toLowerCase() === 'run');
const hasDockerInteractiveArg = (args: string[]): boolean =>
hasArg(args, '-i') || hasArg(args, '--interactive');
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) {
const arg = candidates[index];
if (!arg || arg.startsWith('-')) {
const lower = arg.toLowerCase();
if ([
'-e',
'--env',
'--name',
'--network',
'-v',
'--volume',
'-p',
'--publish',
'--entrypoint',
'-w',
'--workdir',
'-u',
'--user',
'--platform',
'-h',
'--hostname',
].includes(lower)) {
index += 1;
}
continue;
}
if (arg.includes('=') || arg.includes(':') || arg.includes('/')) {
return true;
}
}
return false;
};
const buildStep = (
key: string,
label: string,
example: string,
detail: string,
required: boolean,
satisfied: boolean,
): MCPArgumentHintStep => ({
key,
label,
example,
detail,
required,
satisfied,
});
const buildNextActions = (steps: MCPArgumentHintStep[]): string[] =>
steps
.filter((step) => step.required && !step.satisfied)
.map((step) => `补充 ${step.label},示例:${step.example}`);
export type BusinessArgumentHintTemplate = Omit<MCPBusinessArgumentHint, 'key' | 'argument'>;
const BUSINESS_ARGUMENT_HINTS: Record<string, BusinessArgumentHintTemplate> = {
'api-key': {
category: 'secret',
label: 'API Key',
detail: '用于把外部 API 密钥传给 MCP 服务。除非 README 明确要求命令参数,否则更建议放到环境变量里。',
valueHint: '填真实 key不要截图或粘贴到聊天里。',
sensitive: true,
},
token: {
category: 'secret',
label: 'Token',
detail: '用于鉴权外部平台或远程 MCP 服务。命令行参数可能被进程列表或日志看到。',
valueHint: '优先改用环境变量,例如 GITHUB_TOKEN、API_TOKEN。',
sensitive: true,
},
'access-token': {
category: 'secret',
label: 'Access Token',
detail: '用于访问第三方 API 或私有资源。',
valueHint: '按最小权限创建 token并优先放环境变量。',
sensitive: true,
},
password: {
category: 'secret',
label: '密码',
detail: '密码类参数会进入启动参数列表,风险高于环境变量。',
valueHint: '确认 MCP README 没有环境变量替代方案后再使用。',
sensitive: true,
},
secret: {
category: 'secret',
label: '密钥',
detail: '密钥类参数用于鉴权或签名。',
valueHint: '优先使用环境变量或配置文件,避免明文出现在启动参数里。',
sensitive: true,
},
config: {
category: 'path',
label: '配置文件',
detail: '指向 MCP 服务自己的配置文件。',
valueHint: '填写本机 MCP 进程能访问的绝对路径。',
sensitive: false,
},
'config-file': {
category: 'path',
label: '配置文件',
detail: '指向 MCP 服务自己的配置文件。',
valueHint: 'Windows 建议填写带盘符的绝对路径。',
sensitive: false,
},
c: {
category: 'path',
label: '配置文件',
detail: '短参数通常表示 config以 README 为准。',
valueHint: '填写配置文件路径,或按 README 确认 -c 的含义。',
sensitive: false,
},
directory: {
category: 'path',
label: '授权目录',
detail: '限制文件系统类 MCP 可访问的目录范围。',
valueHint: '填写要授权给 MCP 的工作目录,不要直接授权整个磁盘。',
sensitive: false,
},
dir: {
category: 'path',
label: '目录',
detail: '通常表示文件或项目根目录。',
valueHint: '填写本机绝对路径,确认该 MCP 进程有读取权限。',
sensitive: false,
},
root: {
category: 'path',
label: '根目录',
detail: '通常表示 MCP 服务允许访问或扫描的根目录。',
valueHint: '选择最小必要目录,避免范围过大。',
sensitive: false,
},
workspace: {
category: 'path',
label: '工作区目录',
detail: '通常表示项目或文件系统服务的工作区。',
valueHint: '填写项目目录或业务数据目录。',
sensitive: false,
},
path: {
category: 'path',
label: '路径',
detail: '通常表示文件、目录或可执行程序路径。',
valueHint: '填写本机 MCP 进程可访问的路径。',
sensitive: false,
},
url: {
category: 'endpoint',
label: '服务 URL',
detail: 'MCP 服务要访问的 HTTP/HTTPS 地址。',
valueHint: '填写完整 URL例如 https://api.example.com。',
sensitive: false,
},
endpoint: {
category: 'endpoint',
label: 'Endpoint',
detail: '远程服务或 API 的访问入口。',
valueHint: '按 README 填写 endpoint不要混入 token。',
sensitive: false,
},
'base-url': {
category: 'endpoint',
label: 'Base URL',
detail: '第三方 API 或自建服务的基础地址。',
valueHint: '填写协议、域名和可选端口,不要附带密钥。',
sensitive: false,
},
host: {
category: 'network',
label: '主机地址',
detail: '目标服务主机或本地监听地址。',
valueHint: '本机服务常用 127.0.0.1;远程服务填写域名或 IP。',
sensitive: false,
},
port: {
category: 'network',
label: '端口',
detail: '目标服务端口或 MCP 服务监听端口。',
valueHint: '填写 1-65535 的端口号。',
sensitive: false,
},
transport: {
category: 'mode',
label: '传输模式',
detail: '控制 MCP 服务使用 stdio、sse 或 http 等通信方式。',
valueHint: 'GoNavi 当前本机 MCP 配置使用 stdio除非 README 特别要求,否则填 stdio。',
sensitive: false,
},
mode: {
category: 'mode',
label: '运行模式',
detail: '控制 MCP 服务的业务模式或兼容模式。',
valueHint: '按 README 的枚举值填写。',
sensitive: false,
},
profile: {
category: 'mode',
label: '配置档',
detail: '选择 MCP 服务使用哪套配置或账号档案。',
valueHint: '填写 README 或本机配置中定义的 profile 名称。',
sensitive: false,
},
'read-only': {
category: 'mode',
label: '只读模式',
detail: '限制 MCP 服务只读访问,降低误写风险。',
valueHint: '通常是开关参数,不需要额外值。',
sensitive: false,
},
readonly: {
category: 'mode',
label: '只读模式',
detail: '限制 MCP 服务只读访问,降低误写风险。',
valueHint: '通常是开关参数,不需要额外值。',
sensitive: false,
},
headless: {
category: 'runtime',
label: '无头模式',
detail: '浏览器类 MCP 是否使用无界面浏览器。',
valueHint: '需要真实窗口调试时关闭;自动化运行通常开启。',
sensitive: false,
},
'executable-path': {
category: 'path',
label: '浏览器或程序路径',
detail: '指定 MCP 服务要启动的浏览器或外部程序。',
valueHint: '填写本机绝对路径。',
sensitive: false,
},
repo: {
category: 'path',
label: '仓库路径',
detail: '限制 Git/GitHub 相关 MCP 操作的本地仓库。',
valueHint: '填写目标仓库目录。',
sensitive: false,
},
};
export const normalizeFlagName = (arg: string): string => {
const text = toTrimmedString(arg);
if (!text.startsWith('-') || text === '-' || text === '--') {
return '';
}
const withoutValue = text.split('=')[0];
return withoutValue.replace(/^-+/u, '').trim().toLowerCase();
};
export const sanitizeFlagForDisplay = (arg: string): string => {
const text = toTrimmedString(arg);
const withoutValue = text.split('=')[0];
return withoutValue || text;
};
const inferBusinessArgumentHint = (flag: string): BusinessArgumentHintTemplate | null => {
if (!flag) return null;
if (/(token|api-?key|secret|password|pass|credential)/iu.test(flag)) {
return BUSINESS_ARGUMENT_HINTS.token;
}
if (/(config|file|path|dir|root|workspace|repo|repository)/iu.test(flag)) {
return {
category: 'path',
label: '路径 / 配置',
detail: '参数名看起来像路径、目录或配置文件。',
valueHint: '填写 MCP 进程能访问的本机路径,并尽量限制到最小范围。',
sensitive: false,
};
}
if (/(url|uri|endpoint|base-url|host|addr|address)/iu.test(flag)) {
return {
category: 'endpoint',
label: '地址 / Endpoint',
detail: '参数名看起来像远程服务地址或监听地址。',
valueHint: '填写完整地址或 host密钥不要拼进 URL。',
sensitive: false,
};
}
if (/(port|listen)/iu.test(flag)) {
return BUSINESS_ARGUMENT_HINTS.port;
}
if (/(mode|profile|transport|readonly|read-only|headless)/iu.test(flag)) {
return {
category: 'mode',
label: '模式参数',
detail: '参数名看起来像运行模式、传输模式或开关。',
valueHint: '按 README 的枚举值或开关语义填写。',
sensitive: false,
};
}
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>();
for (const arg of args) {
const flag = normalizeFlagName(arg);
if (!flag || flag === 'stdio') {
continue;
}
const template = resolveBusinessArgumentHintTemplate(flag);
if (!template) {
continue;
}
const key = flag;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push({
key,
argument: sanitizeFlagForDisplay(arg),
...template,
});
}
return result;
};
export const buildMCPArgumentHintProfile = (
command: string,
args?: string[],
): MCPArgumentHintProfile | null => {
const { normalizedCommand, commandName, inlineArgs } = parseCommandField(command);
if (!commandName) {
return null;
}
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 = [
buildStep('yes', '跳过安装确认', '-y', '避免首次启动时等待交互确认。pnpm/yarn 场景可按 README 调整。', commandName === 'npx', hasArg(normalizedArgs, '-y')),
buildStep('package', 'MCP 包名', '@modelcontextprotocol/server-filesystem', 'README 里的 npm 包名或本地包入口。', true, hasPackageLikeArg(normalizedArgs)),
buildStep('stdio', 'stdio 参数', '--stdio', '让服务通过标准输入输出和 GoNavi 通信。', true, hasStdioArg(normalizedArgs)),
buildStep('scope', '授权目录或业务参数', 'C:\\Users\\me\\workspace', '文件系统、浏览器、数据库代理等服务可能还需要目录、端口或模式参数。', false, normalizedArgs.length > 3),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'npx / npm 参数顺序建议',
summary: 'npm 生态 MCP 通常要把安装确认、包名和 --stdio 拆成独立参数标签。',
orderHint: '推荐顺序:-y -> 包名 -> --stdio -> 服务自己的业务参数',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
}
if (commandName === 'node' || commandName === 'bun' || commandName === 'deno') {
const steps = [
buildStep('script', '脚本路径', 'server.js', '本地 MCP Server 的 js/mjs/ts 入口文件或包内启动脚本。', true, hasScriptLikeArg(normalizedArgs) || hasPackageLikeArg(normalizedArgs)),
buildStep('stdio', 'stdio 参数', '--stdio', '如果 README 要求 stdio 模式,请单独填一个 --stdio 或 stdio。', false, hasStdioArg(normalizedArgs)),
buildStep('business', '业务参数', '--port 8811', '只有 README 明确要求时再补,例如工作区路径、端口或模式。', false, normalizedArgs.length > 2),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'Node 脚本参数顺序建议',
summary: 'Node 类启动器的命令只填 node/bun/deno脚本路径和 --stdio 放到参数里。',
orderHint: '推荐顺序:脚本路径 -> --stdio -> 服务自己的业务参数',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
}
if (commandName === 'python' || commandName === 'python3' || commandName === 'py') {
const steps = [
buildStep('module-flag', '模块启动标记或脚本', '-m', '模块方式用 -m脚本方式直接填 server.py。二选一即可。', true, hasArg(normalizedArgs, '-m') || hasScriptLikeArg(normalizedArgs)),
buildStep('module-name', '模块名', 'your_mcp_server', '使用 -m 时这里填模块名,不要带 .py 后缀。', true, hasPythonModuleArg(normalizedArgs) || hasScriptLikeArg(normalizedArgs)),
buildStep('stdio', 'stdio 参数', '--stdio', '如果服务支持 stdio按 README 要求补 --stdio。', false, hasStdioArg(normalizedArgs)),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'Python 参数顺序建议',
summary: 'Python MCP 常见形式是 python -m 模块名,-m 和模块名都要作为独立参数。',
orderHint: '推荐顺序:-m -> 模块名 -> --stdio',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
}
if (commandName === 'uvx' || commandName === 'uv') {
const steps = [
buildStep('package', 'Python MCP 包名', 'mcp-server-fetch', 'uvx 后面通常直接跟已发布的 MCP 包名。', true, hasPackageLikeArg(normalizedArgs)),
buildStep('stdio', 'stdio 参数', '--stdio', '如果 README 要求 stdio单独补 --stdio。', false, hasStdioArg(normalizedArgs)),
buildStep('business', '业务参数', '--config ./config.json', '服务自己的配置文件、模式或地址参数。', false, normalizedArgs.length > 2),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'uvx 参数顺序建议',
summary: 'uvx 类 MCP 通常把包名作为第一个参数,再按 README 补 stdio 或配置参数。',
orderHint: '推荐顺序:包名 -> --stdio -> 服务自己的业务参数',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
}
if (commandName === 'docker') {
const steps = [
buildStep('run', '运行子命令', 'run', 'Docker MCP 通常要以 docker run 启动容器。', true, hasDockerRunArg(normalizedArgs)),
buildStep('interactive', '保持标准输入', '-i', 'MCP 需要 stdio 持续连接Docker 容器必须保留 stdin。', true, hasDockerInteractiveArg(normalizedArgs)),
buildStep('cleanup', '退出后清理容器', '--rm', '测试和日常使用建议自动删除临时容器,避免残留。', false, hasArg(normalizedArgs, '--rm')),
buildStep('image', '镜像名', 'mcp/server-fetch:latest', 'README 里的 Docker 镜像名,放在 docker run 选项之后。', true, hasDockerImageArg(normalizedArgs)),
buildStep('container-env', '容器环境变量', '-e API_KEY=...', '容器内应用需要的 token 通常要用 -e/--env 传给容器。', false, normalizedArgs.some((arg) => arg === '-e' || arg === '--env' || arg.startsWith('-e='))),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'Docker MCP 参数顺序建议',
summary: 'Docker 场景 command 只填 dockerrun、-i、--rm、镜像名和容器参数都放到 args 里。',
orderHint: '推荐顺序run -> --rm -> -i -> -e KEY=VALUE -> 镜像名 -> 服务自己的业务参数',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
}
const steps = [
buildStep('stdio', 'stdio 模式参数', 'stdio 或 --stdio', '多数本机 MCP 二进制需要显式 stdio 参数;以 README 为准。', false, hasStdioArg(normalizedArgs)),
buildStep('business', '业务参数', '--config ./config.json', '二进制自己的配置文件、工作目录、端口或模式参数。', false, normalizedArgs.length > 0),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: '本机可执行文件参数建议',
summary: '自研或已编译 MCP Server 的参数以 README 为准GoNavi 会原样按标签顺序传入。',
orderHint: '常见顺序stdio/--stdio -> 配置文件或业务参数',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
};