feat(mcp): 增强环境变量用途提示

- 新增 MCP 环境变量 key 识别与风险提示

- 在新增 MCP 表单展示 env 用途、占位值和 Docker 边界提醒

- 在 inspect_mcp_draft 输出脱敏 envHints 供 AI 解释参数
This commit is contained in:
Syngnat
2026-06-11 21:34:04 +08:00
parent 890d693102
commit a9eed57cf7
9 changed files with 551 additions and 2 deletions

View File

@@ -93,14 +93,14 @@ export const BUILTIN_AI_INSPECTION_MCP_TOOL_INFO: AIBuiltinToolInfo[] = [
icon: "🧪",
desc: "校验 MCP 新增草稿",
detail:
"按完整启动命令或分字段草稿试算 GoNavi 的 MCP 新增配置,返回自动拆分结果、启动预览、可应用草稿、字段校验问题、推荐模板和下一步修复建议。适合用户贴出一整行 MCP 启动命令、问 command/args/env/timeout 该怎么拆,或保存前想确认配置有没有明显问题时使用。",
"按完整启动命令或分字段草稿试算 GoNavi 的 MCP 新增配置,返回自动拆分结果、启动预览、可应用草稿、环境变量用途提示、字段校验问题、推荐模板和下一步修复建议。适合用户贴出一整行 MCP 启动命令、问 command/args/env/timeout 该怎么拆,或保存前想确认配置有没有明显问题时使用。",
params: "fullCommand?, command?, args?, envText?, timeoutSeconds?, templateKey?, name?",
tool: {
type: "function",
function: {
name: "inspect_mcp_draft",
description:
"校验一份待新增的 MCP 服务草稿。支持传 fullCommand/rawCommand/commandLine 让 GoNavi 自动拆分,也支持传 command、args、envText、timeoutSeconds 和 templateKey 做分字段校验返回解析后的字段、启动命令预览、suggestedServerSeed、错误/告警、推荐模板和 nextActions。适用于用户贴出 MCP README 启动命令、问新增 MCP 参数怎么填、或 AI 准备指导用户保存前,先用真实校验器试算。",
"校验一份待新增的 MCP 服务草稿。支持传 fullCommand/rawCommand/commandLine 让 GoNavi 自动拆分,也支持传 command、args、envText、timeoutSeconds 和 templateKey 做分字段校验返回解析后的字段、启动命令预览、suggestedServerSeed、环境变量 key 的用途和风险提示、错误/告警、推荐模板和 nextActions。适用于用户贴出 MCP README 启动命令、问新增 MCP 参数怎么填、或 AI 准备指导用户保存前,先用真实校验器试算。",
parameters: {
type: "object",
properties: {

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { buildMCPEnvHintProfile } from './mcpEnvHints';
describe('mcpEnvHints', () => {
it('explains common secret and proxy env vars without exposing values', () => {
const profile = buildMCPEnvHintProfile('uvx', ['mcp-server-github', '--stdio'], {
GITHUB_TOKEN: 'ghp_real_secret_value',
HTTPS_PROXY: 'http://127.0.0.1:7890',
});
expect(profile?.envVarCount).toBe(2);
expect(profile?.secretLikeCount).toBe(1);
expect(profile?.items.find((item) => item.key === 'GITHUB_TOKEN')).toMatchObject({
label: 'GitHub Token',
category: 'secret',
sensitive: true,
known: true,
});
expect(profile?.items.find((item) => item.key === 'HTTPS_PROXY')).toMatchObject({
label: 'HTTPS 代理',
category: 'proxy',
sensitive: false,
known: true,
});
expect(JSON.stringify(profile)).not.toContain('ghp_real_secret_value');
expect(JSON.stringify(profile)).not.toContain('127.0.0.1:7890');
});
it('warns when secret env vars still contain placeholders', () => {
const profile = buildMCPEnvHintProfile('npx', ['-y', '@modelcontextprotocol/server-github', '--stdio'], {
GITHUB_TOKEN: '...',
OPENAI_API_KEY: '',
});
expect(profile?.warnings).toContain('1 个环境变量值为空,测试前需要补齐或删除。');
expect(profile?.warnings).toContain('1 个环境变量看起来仍是示例占位值。');
expect(profile?.nextActions.join('\n')).toContain('GITHUB_TOKEN');
expect(profile?.nextActions.join('\n')).toContain('OPENAI_API_KEY');
});
it('explains docker env forwarding boundaries', () => {
const profile = buildMCPEnvHintProfile('docker', ['run', '--rm', '-i', 'mcp/server-fetch:latest'], {
API_KEY: 'secret',
});
expect(profile?.warnings).toContain('command=docker 时,这里的环境变量只传给 docker CLI不会自动进入容器。');
expect(profile?.nextActions.join('\n')).toContain('-e KEY=VALUE');
});
it('does not warn about docker container env when args already forward env values', () => {
const profile = buildMCPEnvHintProfile('docker', ['run', '--rm', '-i', '-e', 'API_KEY=secret', 'mcp/server-fetch:latest'], {
DOCKER_HOST: 'npipe:////./pipe/docker_engine',
});
expect(profile?.items[0]).toMatchObject({
key: 'DOCKER_HOST',
label: 'Docker Daemon 地址',
category: 'runtime',
});
expect(profile?.warnings.join('\n')).not.toContain('不会自动进入容器');
});
});

View File

@@ -0,0 +1,287 @@
import { splitShellLikeCommand } from './mcpCommandDraft';
export type MCPEnvHintCategory = 'secret' | 'endpoint' | 'proxy' | 'path' | 'runtime' | 'generic';
export interface MCPEnvHintItem {
key: string;
category: MCPEnvHintCategory;
label: string;
detail: string;
valueHint: string;
sensitive: boolean;
known: boolean;
empty: boolean;
placeholder: boolean;
}
export interface MCPEnvHintProfile {
envVarCount: number;
secretLikeCount: number;
endpointLikeCount: number;
items: MCPEnvHintItem[];
warnings: string[];
nextActions: string[];
}
interface KnownEnvHint {
category: MCPEnvHintCategory;
label: string;
detail: string;
valueHint: string;
sensitive?: boolean;
}
const KNOWN_ENV_HINTS: Record<string, KnownEnvHint> = {
GITHUB_TOKEN: {
category: 'secret',
label: 'GitHub Token',
detail: '通常给 GitHub MCP 读取仓库、Issue、PR 或 Actions 使用。',
valueHint: '填 GitHub Personal Access Token按 MCP README 要求授予最小权限。',
sensitive: true,
},
GITLAB_TOKEN: {
category: 'secret',
label: 'GitLab Token',
detail: '通常给 GitLab MCP 访问项目、Merge Request 或 CI 使用。',
valueHint: '填 GitLab Access Token并限制到需要访问的项目范围。',
sensitive: true,
},
OPENAI_API_KEY: {
category: 'secret',
label: 'OpenAI API Key',
detail: '给依赖 OpenAI API 的 MCP 服务调用模型或 embedding 接口。',
valueHint: '填真实 API Key不要写到 command、args 或聊天消息里。',
sensitive: true,
},
ANTHROPIC_API_KEY: {
category: 'secret',
label: 'Anthropic API Key',
detail: '给依赖 Anthropic Claude API 的 MCP 服务使用。',
valueHint: '填真实 API Key确认服务确实需要该变量后再配置。',
sensitive: true,
},
GEMINI_API_KEY: {
category: 'secret',
label: 'Gemini API Key',
detail: '给依赖 Google Gemini API 的 MCP 服务使用。',
valueHint: '填真实 API Key也有服务会要求 GOOGLE_API_KEY。',
sensitive: true,
},
GOOGLE_API_KEY: {
category: 'secret',
label: 'Google API Key',
detail: '给 Google/Gemini/Maps/Search 类 MCP 服务使用。',
valueHint: '填真实 API Key并确认 README 要求的是 GOOGLE_API_KEY 还是 GEMINI_API_KEY。',
sensitive: true,
},
SLACK_BOT_TOKEN: {
category: 'secret',
label: 'Slack Bot Token',
detail: '给 Slack MCP 读取频道、消息或发送通知使用。',
valueHint: '填 xoxb- 开头的 Bot Token并控制 workspace 权限。',
sensitive: true,
},
NOTION_API_KEY: {
category: 'secret',
label: 'Notion API Key',
detail: '给 Notion MCP 访问页面、数据库或 workspace 内容使用。',
valueHint: '填 Notion integration secret并只授权需要的页面。',
sensitive: true,
},
DATABASE_URL: {
category: 'endpoint',
label: '数据库连接串',
detail: '给 MCP 服务自己连接数据库使用;这会把数据库连接信息交给该 MCP 进程。',
valueHint: '只在确实要让该 MCP 直连数据库时填写,优先考虑使用 GoNavi MCP 避免密码外泄。',
sensitive: true,
},
HTTP_PROXY: {
category: 'proxy',
label: 'HTTP 代理',
detail: '让 MCP 进程访问 HTTP 资源时走指定代理。',
valueHint: '填 http://host:port如果代理带账号密码按敏感变量处理。',
},
HTTPS_PROXY: {
category: 'proxy',
label: 'HTTPS 代理',
detail: '让 MCP 进程访问 HTTPS 资源时走指定代理。',
valueHint: '填 http://host:port 或 https://host:port。',
},
NO_PROXY: {
category: 'proxy',
label: '代理绕过列表',
detail: '指定哪些域名或地址不走代理。',
valueHint: '逗号分隔,例如 localhost,127.0.0.1,.corp.local。',
},
DOCKER_HOST: {
category: 'runtime',
label: 'Docker Daemon 地址',
detail: '给 docker CLI 指定连接哪个 Docker Engine。',
valueHint: 'Windows 常见为 npipe:////./pipe/docker_engine远端 Docker 请确认安全边界。',
},
GONAVI_MCP_HTTP_TOKEN: {
category: 'secret',
label: 'GoNavi MCP HTTP Token',
detail: '给远程 MCP HTTP 服务开启 Bearer Token 鉴权时使用。',
valueHint: '填高熵随机 token不要复用数据库密码或模型 API Key。',
sensitive: true,
},
NODE_ENV: {
category: 'runtime',
label: 'Node 运行环境',
detail: '影响部分 Node MCP 服务的日志、调试或生产模式。',
valueHint: '通常填 production、development 或 README 指定值。',
},
LOG_LEVEL: {
category: 'runtime',
label: '日志级别',
detail: '控制 MCP 服务输出多少日志。',
valueHint: '常见值为 debug、info、warn、error排障时可临时调高。',
},
};
const SECRET_KEY_RE = /(TOKEN|API[_-]?KEY|SECRET|PASSWORD|PASS|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|DATABASE_URL|DSN)/iu;
const ENDPOINT_KEY_RE = /(URL|URI|ENDPOINT|BASE[_-]?URL|HOST|ADDR|ADDRESS)/iu;
const PROXY_KEY_RE = /PROXY/iu;
const PATH_KEY_RE = /(PATH|DIR|ROOT|HOME|FILE|CONFIG)/iu;
const RUNTIME_KEY_RE = /^(NODE_ENV|LOG_LEVEL|DEBUG|ENV|TZ)$/iu;
const PLACEHOLDER_VALUE_RE = /^(\*+|\.{3}|<[^>]+>|your[-_ ].*|change[_-]?me|replace[_-]?me|xxx+|todo|token|api[_-]?key)$/iu;
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const normalizeEnvKey = (key: string): string => toTrimmedString(key).toUpperCase();
const normalizeCommandName = (command: string): string => {
const { tokens } = splitShellLikeCommand(command);
const raw = toTrimmedString(tokens[0] || command);
return (raw.split(/[\\/]/u).pop() || raw)
.replace(/\.(exe|cmd|bat|ps1)$/iu, '')
.toLowerCase();
};
const inferEnvHint = (key: string): KnownEnvHint => {
if (SECRET_KEY_RE.test(key)) {
return {
category: 'secret',
label: '密钥 / Token',
detail: '变量名看起来像密钥、Token、密码或连接串。',
valueHint: '填真实值,但只保存在本机 MCP 配置里;不要放到 command、args 或聊天内容。',
sensitive: true,
};
}
if (PROXY_KEY_RE.test(key)) {
return {
category: 'proxy',
label: '代理配置',
detail: '变量名看起来像网络代理设置。',
valueHint: '按 README 或企业代理格式填写,例如 http://127.0.0.1:7890。',
};
}
if (ENDPOINT_KEY_RE.test(key)) {
return {
category: 'endpoint',
label: '服务地址',
detail: '变量名看起来像服务地址、接口地址或主机配置。',
valueHint: '填写 MCP Server 要访问的 URL、host 或 endpoint。',
};
}
if (PATH_KEY_RE.test(key)) {
return {
category: 'path',
label: '路径 / 配置文件',
detail: '变量名看起来像本地路径、目录或配置文件位置。',
valueHint: '填写本机 MCP 进程能访问的绝对路径Windows 路径建议保留盘符。',
};
}
if (RUNTIME_KEY_RE.test(key)) {
return {
category: 'runtime',
label: '运行时开关',
detail: '变量名看起来像运行环境、日志或调试开关。',
valueHint: '按 README 指定的枚举值填写。',
};
}
return {
category: 'generic',
label: '自定义配置',
detail: '未命中内置变量库,按 MCP README 对应字段说明填写。',
valueHint: '确认变量名大小写和 README 完全一致。',
};
};
const isPlaceholderValue = (value: string): boolean => {
const text = toTrimmedString(value);
if (!text) {
return false;
}
return PLACEHOLDER_VALUE_RE.test(text) || text.includes('...');
};
const buildEnvHintItem = ([key, value]: [string, string]): MCPEnvHintItem => {
const normalizedKey = normalizeEnvKey(key);
const knownHint = KNOWN_ENV_HINTS[normalizedKey];
const hint = knownHint || inferEnvHint(normalizedKey);
return {
key: normalizedKey,
category: hint.category,
label: hint.label,
detail: hint.detail,
valueHint: hint.valueHint,
sensitive: hint.sensitive === true || SECRET_KEY_RE.test(normalizedKey),
known: Boolean(knownHint),
empty: toTrimmedString(value) === '',
placeholder: isPlaceholderValue(value),
};
};
export const buildMCPEnvHintProfile = (
command: string,
args: string[] | undefined,
env: Record<string, string> | undefined,
): MCPEnvHintProfile | null => {
const items = Object.entries(env || {})
.sort(([left], [right]) => normalizeEnvKey(left).localeCompare(normalizeEnvKey(right)))
.map(buildEnvHintItem);
if (items.length === 0) {
return null;
}
const warnings: string[] = [];
const nextActions: string[] = [];
const secretLikeCount = items.filter((item) => item.sensitive).length;
const endpointLikeCount = items.filter((item) => item.category === 'endpoint').length;
const emptyItems = items.filter((item) => item.empty);
const placeholderItems = items.filter((item) => item.placeholder);
const dockerCommand = normalizeCommandName(command) === 'docker';
const dockerEnvForwarded = (args || []).some((arg) => ['-e', '--env'].includes(toTrimmedString(arg).toLowerCase()) || toTrimmedString(arg).startsWith('-e='));
if (emptyItems.length > 0) {
warnings.push(`${emptyItems.length} 个环境变量值为空,测试前需要补齐或删除。`);
nextActions.push(`补齐 ${emptyItems.map((item) => item.key).slice(0, 3).join('、')} 的值,或删除不需要的变量。`);
}
if (placeholderItems.length > 0) {
warnings.push(`${placeholderItems.length} 个环境变量看起来仍是示例占位值。`);
nextActions.push(`${placeholderItems.map((item) => item.key).slice(0, 3).join('、')} 替换成真实值后再测试工具发现。`);
}
if (dockerCommand && items.length > 0 && !dockerEnvForwarded) {
warnings.push('command=docker 时,这里的环境变量只传给 docker CLI不会自动进入容器。');
nextActions.push('如果容器内 MCP 需要这些变量,请在 args 里按 README 增加 -e KEY=VALUE 或 --env KEY=VALUE。');
}
if (secretLikeCount > 0) {
nextActions.push('密钥类变量只保存在本机配置不要把真实值发到聊天、Issue 或截图里。');
}
if (nextActions.length === 0) {
nextActions.push('环境变量 key 已可识别;测试失败时优先核对 README 要求的变量名大小写。');
}
return {
envVarCount: items.length,
secretLikeCount,
endpointLikeCount,
items,
warnings,
nextActions,
};
};