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

@@ -0,0 +1,115 @@
import React from 'react';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import { buildMCPEnvHintProfile } from '../../utils/mcpEnvHints';
import { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock';
interface AIMCPEnvHintsProps {
command: string;
args?: string[];
env?: Record<string, string>;
cardBorder: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
}
const categoryLabel = {
secret: '密钥',
endpoint: '地址',
proxy: '代理',
path: '路径',
runtime: '运行时',
generic: '自定义',
};
const categoryColor = {
secret: '#b45309',
endpoint: '#2563eb',
proxy: '#0f766e',
path: '#7c3aed',
runtime: '#475569',
generic: '#64748b',
};
const AIMCPEnvHints: React.FC<AIMCPEnvHintsProps> = ({
command,
args,
env,
cardBorder,
darkMode,
overlayTheme,
}) => {
const profile = buildMCPEnvHintProfile(command, args, env);
if (!profile) {
return null;
}
return (
<div
style={{
padding: '10px 12px',
borderRadius: 10,
border: `1px dashed ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.7)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}></div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
{profile.envVarCount} {profile.secretLikeCount} key value
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 8 }}>
{profile.items.map((item) => (
<div
key={item.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 }}>{item.key}</code>
<span
style={{
padding: '1px 7px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: categoryColor[item.category],
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)',
}}
>
{categoryLabel[item.category]}
</span>
{item.known ? <span style={buildMCPHintStyle('#16a34a')}></span> : null}
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}>{item.label}</div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>{item.detail}</div>
<div style={buildMCPHintStyle(item.empty || item.placeholder ? '#b45309' : overlayTheme.mutedText)}>
{item.valueHint}
{item.empty ? ' 当前值为空。' : ''}
{item.placeholder ? ' 当前像示例占位值。' : ''}
</div>
</div>
))}
</div>
{profile.warnings.length > 0 ? (
<div style={buildMCPHintStyle('#b45309')}>
{profile.warnings.join('')}
</div>
) : null}
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
{profile.nextActions.join('')}
</div>
</div>
);
};
export default AIMCPEnvHints;

View File

@@ -109,4 +109,44 @@ describe('AIMCPServerCard', () => {
expect(markup).toContain('把脚本名、模块名、--stdio 和环境变量拆到命令参数或环境变量里');
expect(markup).toContain('命令参数可能缺少脚本或模块名');
});
it('renders env key purpose hints without requiring users to guess common MCP variables', () => {
const markup = renderToStaticMarkup(
<AIMCPServerCard
server={{
id: 'mcp-2',
name: 'GitHub MCP',
transport: 'stdio',
command: 'uvx',
args: ['mcp-server-github', '--stdio'],
env: {
GITHUB_TOKEN: '...',
HTTPS_PROXY: 'http://127.0.0.1:7890',
},
enabled: true,
timeoutSeconds: 20,
}}
serverTools={[]}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
inputBg="#fff"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
loading={false}
onChange={() => {}}
onTest={() => {}}
onSave={() => {}}
onDelete={() => {}}
/>,
);
expect(markup).toContain('环境变量用途提示');
expect(markup).toContain('只解释 key 的用途和风险,不会显示 value');
expect(markup).toContain('GITHUB_TOKEN');
expect(markup).toContain('GitHub Token');
expect(markup).toContain('HTTPS_PROXY');
expect(markup).toContain('HTTPS 代理');
expect(markup).toContain('当前像示例占位值');
expect(markup).toContain('密钥类变量只保存在本机配置');
});
});

View File

@@ -8,6 +8,7 @@ import type { ParsedMCPEnvDraft } from '../../utils/mcpEnvDraft';
import type { MCPServerDraftValidation } from '../../utils/mcpServerValidation';
import AIMCPHelpBlock, { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock';
import AIMCPArgumentHints from './AIMCPArgumentHints';
import AIMCPEnvHints from './AIMCPEnvHints';
import AIMCPServerValidationPanel from './AIMCPServerValidationPanel';
import AIMCPToolSchemaSummary from './AIMCPToolSchemaSummary';
@@ -166,6 +167,14 @@ const AIMCPServerFormPanel: React.FC<AIMCPServerFormPanelProps> = ({
: `已识别 ${parsedEnvDraft.validLines} 条环境变量。`
: '每行都要写成 KEY=VALUE没有等号或 key 含空格的行不会保存。'}
</div>
<AIMCPEnvHints
command={server.command}
args={server.args}
env={parsedEnvDraft.env}
cardBorder={cardBorder}
darkMode={darkMode}
overlayTheme={overlayTheme}
/>
</AIMCPHelpBlock>
<AIMCPServerValidationPanel

View File

@@ -383,6 +383,9 @@ describe('aiLocalToolExecutor AI config inspection tools', () => {
expect(result.content).toContain('"command":"uvx"');
expect(result.content).toContain('"args":["mcp-server-github","--stdio"]');
expect(result.content).toContain('"envKeys":["GITHUB_TOKEN"]');
expect(result.content).toContain('"envHints"');
expect(result.content).toContain('"label":"GitHub Token"');
expect(result.content).toContain('"secretLikeCount":1');
expect(result.content).toContain('"launchCommandPreview":"uvx mcp-server-github --stdio"');
expect(result.content).toContain('"suggestedServerSeed"');
expect(result.content).toContain('"name":"mcp-server-github"');

View File

@@ -18,6 +18,18 @@ describe('aiMCPDraftInspectionInsights', () => {
expect(snapshot.input.fullCommand).toBe('GITHUB_TOKEN=*** uvx mcp-server-github --stdio');
expect(snapshot.draft.launchCommandPreview).toBe('uvx mcp-server-github --stdio');
expect(snapshot.draft.envKeys).toEqual(['GITHUB_TOKEN']);
expect(snapshot.draft.envHints).toMatchObject({
envVarCount: 1,
secretLikeCount: 1,
items: [{
key: 'GITHUB_TOKEN',
category: 'secret',
label: 'GitHub Token',
sensitive: true,
known: true,
}],
});
expect(snapshot.draft.envHints?.nextActions.join('\n')).toContain('密钥类变量只保存在本机配置');
expect(snapshot.draft.timeoutSeconds).toBe(45);
expect(snapshot.draft.suggestedServerSeed).toMatchObject({
name: 'mcp-server-github',

View File

@@ -1,5 +1,6 @@
import type { AIMCPServerConfig } from '../../types';
import { parseMCPCommandDraft, type ParseMCPCommandDraftResult } from '../../utils/mcpCommandDraft';
import { buildMCPEnvHintProfile } from '../../utils/mcpEnvHints';
import { parseMCPEnvDraft } from '../../utils/mcpEnvDraft';
import { buildMCPLaunchPreview } from '../../utils/mcpServerGuidance';
import { buildMCPServerDraftSeed } from '../../utils/mcpServerDraftSeed';
@@ -171,6 +172,7 @@ export const buildMCPDraftInspectionSnapshot = (args: Record<string, unknown> =
const validation = validateMCPServerDraft(server, parsedEnvDraft);
const issueKeys = new Set(validation.issues.map((issue) => issue.key));
const recommendedTemplate = resolveRecommendedTemplate(command, commandArgs);
const envHintProfile = buildMCPEnvHintProfile(command, commandArgs, env);
const suggestedServerSeed = buildMCPServerDraftSeed({
name: toTrimmedString(args.name ?? args.serverName) || undefined,
command,
@@ -207,6 +209,24 @@ export const buildMCPDraftInspectionSnapshot = (args: Record<string, unknown> =
args: commandArgs,
envKeys: Object.keys(env).sort(),
envVarCount: Object.keys(env).length,
envHints: envHintProfile ? {
envVarCount: envHintProfile.envVarCount,
secretLikeCount: envHintProfile.secretLikeCount,
endpointLikeCount: envHintProfile.endpointLikeCount,
items: envHintProfile.items.map((item) => ({
key: item.key,
category: item.category,
label: item.label,
detail: item.detail,
valueHint: item.valueHint,
sensitive: item.sensitive,
known: item.known,
empty: item.empty,
placeholder: item.placeholder,
})),
warnings: envHintProfile.warnings,
nextActions: envHintProfile.nextActions,
} : null,
invalidEnvLines: parsedEnvDraft?.invalidLines || [],
timeoutSeconds: server.timeoutSeconds,
launchCommandPreview: buildMCPLaunchPreview(command, commandArgs),

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,
};
};