diff --git a/frontend/src/components/ai/AIMCPArgumentHints.test.tsx b/frontend/src/components/ai/AIMCPArgumentHints.test.tsx index 28ccb3b..3104d64 100644 --- a/frontend/src/components/ai/AIMCPArgumentHints.test.tsx +++ b/frontend/src/components/ai/AIMCPArgumentHints.test.tsx @@ -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( + , + ); + }); + + 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('位置参数'); + }); }); diff --git a/frontend/src/components/ai/AIMCPArgumentHints.tsx b/frontend/src/components/ai/AIMCPArgumentHints.tsx index 3294b7d..4a406b6 100644 --- a/frontend/src/components/ai/AIMCPArgumentHints.tsx +++ b/frontend/src/components/ai/AIMCPArgumentHints.tsx @@ -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 = ({ 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 = ({ ))} + {argumentHints.length > 0 ? ( +
+
+ 参数逐项说明 +
+
+ {argumentHints.map((hint) => ( +
+
+ + {hint.argument} + + + {businessHintCategoryLabel[hint.category]} + + {hint.sensitive ? 值已脱敏 : null} +
+
{hint.label}
+
{hint.detail}
+
+ 应填:{hint.valueHint} +
+
+ ))} +
+
+ ) : null} {profile.businessHints.length > 0 ? (
diff --git a/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts index 5f78eac..cc2f8e5 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts @@ -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'); diff --git a/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts b/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts index 4820d6c..47de81c 100644 --- a/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts +++ b/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts @@ -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 = summary: argumentHintProfile.summary, orderHint: argumentHintProfile.orderHint, steps: argumentHintProfile.steps, + argumentDetailHints: buildMCPArgumentDetailHints(argumentHintProfile.commandName, commandArgs), businessHints: argumentHintProfile.businessHints, nextActions: argumentHintProfile.nextActions, } : null, diff --git a/frontend/src/utils/mcpArgumentDetailHints.ts b/frontend/src/utils/mcpArgumentDetailHints.ts new file mode 100644 index 0000000..15e1b81 --- /dev/null +++ b/frontend/src/utils/mcpArgumentDetailHints.ts @@ -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; +}; diff --git a/frontend/src/utils/mcpArgumentHints.test.ts b/frontend/src/utils/mcpArgumentHints.test.ts index 5c5e55f..a0d4b3a 100644 --- a/frontend/src/utils/mcpArgumentHints.test.ts +++ b/frontend/src/utils/mcpArgumentHints.test.ts @@ -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'); }); }); diff --git a/frontend/src/utils/mcpArgumentHints.ts b/frontend/src/utils/mcpArgumentHints.ts index b6a692b..42def4d 100644 --- a/frontend/src/utils/mcpArgumentHints.ts +++ b/frontend/src/utils/mcpArgumentHints.ts @@ -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; +export type BusinessArgumentHintTemplate = Omit; const BUSINESS_ARGUMENT_HINTS: Record = { 'api-key': { @@ -330,7 +330,7 @@ const BUSINESS_ARGUMENT_HINTS: Record = { }, }; -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(); @@ -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; }