diff --git a/frontend/src/components/ai/AIMCPEnvHints.tsx b/frontend/src/components/ai/AIMCPEnvHints.tsx new file mode 100644 index 0000000..a67cdde --- /dev/null +++ b/frontend/src/components/ai/AIMCPEnvHints.tsx @@ -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; + 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 = ({ + command, + args, + env, + cardBorder, + darkMode, + overlayTheme, +}) => { + const profile = buildMCPEnvHintProfile(command, args, env); + if (!profile) { + return null; + } + + return ( +
+
环境变量用途提示
+
+ 已识别 {profile.envVarCount} 个变量,其中 {profile.secretLikeCount} 个像密钥;这里只解释 key 的用途和风险,不会显示 value。 +
+
+ {profile.items.map((item) => ( +
+
+ {item.key} + + {categoryLabel[item.category]} + + {item.known ? 已识别 : null} +
+
{item.label}
+
{item.detail}
+
+ 应填:{item.valueHint} + {item.empty ? ' 当前值为空。' : ''} + {item.placeholder ? ' 当前像示例占位值。' : ''} +
+
+ ))} +
+ {profile.warnings.length > 0 ? ( +
+ 注意:{profile.warnings.join(';')} +
+ ) : null} +
+ 下一步:{profile.nextActions.join(';')} +
+
+ ); +}; + +export default AIMCPEnvHints; diff --git a/frontend/src/components/ai/AIMCPServerCard.test.tsx b/frontend/src/components/ai/AIMCPServerCard.test.tsx index 16504ee..1ec6bb2 100644 --- a/frontend/src/components/ai/AIMCPServerCard.test.tsx +++ b/frontend/src/components/ai/AIMCPServerCard.test.tsx @@ -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( + {}} + 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('密钥类变量只保存在本机配置'); + }); }); diff --git a/frontend/src/components/ai/AIMCPServerFormPanel.tsx b/frontend/src/components/ai/AIMCPServerFormPanel.tsx index 5821aa5..bc7265c 100644 --- a/frontend/src/components/ai/AIMCPServerFormPanel.tsx +++ b/frontend/src/components/ai/AIMCPServerFormPanel.tsx @@ -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 = ({ : `已识别 ${parsedEnvDraft.validLines} 条环境变量。` : '每行都要写成 KEY=VALUE;没有等号或 key 含空格的行不会保存。'} + { 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"'); diff --git a/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts b/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts index 8fdaafa..fddc18d 100644 --- a/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts +++ b/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts @@ -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', diff --git a/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts b/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts index bb3e9de..d3132fe 100644 --- a/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts +++ b/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts @@ -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 = 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 = 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), diff --git a/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts index e741038..491b715 100644 --- a/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts @@ -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: { diff --git a/frontend/src/utils/mcpEnvHints.test.ts b/frontend/src/utils/mcpEnvHints.test.ts new file mode 100644 index 0000000..8aaad8b --- /dev/null +++ b/frontend/src/utils/mcpEnvHints.test.ts @@ -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('不会自动进入容器'); + }); +}); diff --git a/frontend/src/utils/mcpEnvHints.ts b/frontend/src/utils/mcpEnvHints.ts new file mode 100644 index 0000000..f931779 --- /dev/null +++ b/frontend/src/utils/mcpEnvHints.ts @@ -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 = { + 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 | 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, + }; +};