mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-12 16:02:48 +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),
|
||||
|
||||
Reference in New Issue
Block a user