mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 02:19:58 +08:00
✨ feat(mcp): 增强环境变量用途提示
- 新增 MCP 环境变量 key 识别与风险提示 - 在新增 MCP 表单展示 env 用途、占位值和 Docker 边界提醒 - 在 inspect_mcp_draft 输出脱敏 envHints 供 AI 解释参数
This commit is contained in:
115
frontend/src/components/ai/AIMCPEnvHints.tsx
Normal file
115
frontend/src/components/ai/AIMCPEnvHints.tsx
Normal 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;
|
||||
@@ -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('密钥类变量只保存在本机配置');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
63
frontend/src/utils/mcpEnvHints.test.ts
Normal file
63
frontend/src/utils/mcpEnvHints.test.ts
Normal 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('不会自动进入容器');
|
||||
});
|
||||
});
|
||||
287
frontend/src/utils/mcpEnvHints.ts
Normal file
287
frontend/src/utils/mcpEnvHints.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user