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),