feat(ai-mcp): 补全结构探针并优化客户端接入体验

- 新增 get_indexes、get_foreign_keys、get_triggers 内置工具与 MCP Server 对应实现
- 拆分 AI 设置中的 MCP 接入面板和服务卡片,补充参数提示与客户端状态展示
- 补齐前后端测试与真实页面验证,降低 AI 设置区域的臃肿度
This commit is contained in:
Syngnat
2026-06-07 22:06:24 +08:00
parent 7039eae9c7
commit eff2f7f63a
17 changed files with 1283 additions and 342 deletions

View File

@@ -28,6 +28,13 @@ describe('AIChatPanel message render isolation', () => {
expect(source).toContain('buildAvailableAIChatTools');
});
it('teaches the runtime to use deeper schema tools when analyzing structure details', () => {
expect(source).toContain('get_indexes、get_foreign_keys、get_triggers、get_table_ddl');
expect(source).toContain("case 'get_indexes':");
expect(source).toContain("case 'get_foreign_keys':");
expect(source).toContain("case 'get_triggers':");
});
it('keeps the v2 history mode sorted by the latest updated session first', () => {
expect(source).toContain('const orderedAISessions = useMemo(');
expect(source).toContain('right.updatedAt - left.updatedAt');

View File

@@ -1207,10 +1207,11 @@ ${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体
SQL 生成规则(极重要,必须严格遵守):
7. 【字段精确性 - 绝对红线】生成 SQL 之前,必须先调用 get_columns 获取目标表的真实字段列表。SQL 中的每一个字段名必须与 get_columns 返回的 field 字段完全一致(区分大小写)。不得自行拼凑、缩写或联想字段名(例如字段是 channel 就必须写 channel不得写成 pay_channel
8. 生成 SQL 时禁止使用 "database.table" 格式的限定前缀,只写表名本身
9. 报告结果时,连接名/ID 和数据库名必须严格来自同一个 get_tables 调用的实际参数。禁止将 A 连接的 connectionId 与 B 连接的 dbName 混搭
10. 如果有多个名称相似的数据库,请明确告诉用户目标表具体位于哪个数据库
11. 【关键】每个 SQL 代码块的第一行必须添加上下文声明注释,格式严格为:-- @context connectionId=<连接ID> dbName=<数据库名>。connectionId 和 dbName 必须来自同一个成功的 get_tables 调用(即你在该调用中传入的实际参数值)。示例:
8. 如果用户在问索引优化、联表关系、触发器副作用、约束或 DDL 细节,在 get_columns 之后继续按需调用 get_indexes、get_foreign_keys、get_triggers、get_table_ddl再给结论
9. 生成 SQL 时禁止使用 "database.table" 格式的限定前缀,只写表名本身
10. 报告结果时,连接名/ID 和数据库名必须严格来自同一个 get_tables 调用的实际参数。禁止将 A 连接的 connectionId 与 B 连接的 dbName 混搭
11. 如果有多个名称相似的数据库,请明确告诉用户目标表具体位于哪个数据库。
12. 【关键】每个 SQL 代码块的第一行必须添加上下文声明注释,格式严格为:-- @context connectionId=<连接ID> dbName=<数据库名>。connectionId 和 dbName 必须来自同一个成功的 get_tables 调用(即你在该调用中传入的实际参数值)。示例:
\`\`\`sql
-- @context connectionId=1770778676549 dbName=mkefu_test
SELECT * FROM users WHERE status = 1;
@@ -1346,6 +1347,60 @@ SELECT * FROM users WHERE status = 1;
} else { resStr = 'Connection not found'; }
break;
}
case 'get_indexes': {
const conn = useStore.getState().connections.find(c => c.id === args.connectionId);
if (conn) {
try {
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
const { DBGetIndexes } = await import('../../wailsjs/go/app/App');
const indexRes = await DBGetIndexes(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
if (indexRes?.success && Array.isArray(indexRes.data)) {
resStr = JSON.stringify(indexRes.data);
success = true;
} else { resStr = indexRes?.message || 'Failed to fetch indexes'; }
} catch (e: any) {
resStr = `获取索引定义失败: ${e?.message || e}`;
}
} else { resStr = 'Connection not found'; }
break;
}
case 'get_foreign_keys': {
const conn = useStore.getState().connections.find(c => c.id === args.connectionId);
if (conn) {
try {
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
const { DBGetForeignKeys } = await import('../../wailsjs/go/app/App');
const foreignKeyRes = await DBGetForeignKeys(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
if (foreignKeyRes?.success && Array.isArray(foreignKeyRes.data)) {
resStr = JSON.stringify(foreignKeyRes.data);
success = true;
} else { resStr = foreignKeyRes?.message || 'Failed to fetch foreign keys'; }
} catch (e: any) {
resStr = `获取外键关系失败: ${e?.message || e}`;
}
} else { resStr = 'Connection not found'; }
break;
}
case 'get_triggers': {
const conn = useStore.getState().connections.find(c => c.id === args.connectionId);
if (conn) {
try {
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
const { DBGetTriggers } = await import('../../wailsjs/go/app/App');
const triggerRes = await DBGetTriggers(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
if (triggerRes?.success && Array.isArray(triggerRes.data)) {
resStr = JSON.stringify(triggerRes.data);
success = true;
} else { resStr = triggerRes?.message || 'Failed to fetch triggers'; }
} catch (e: any) {
resStr = `获取触发器定义失败: ${e?.message || e}`;
}
} else { resStr = 'Connection not found'; }
break;
}
case 'get_table_ddl': {
const conn = useStore.getState().connections.find(c => c.id === args.connectionId);
if (conn) {

View File

@@ -26,17 +26,22 @@ describe('AISettingsModal edit password behavior', () => {
expect(source).toContain('新增 Skill');
});
it('explains external MCP installation and renders selectable client install states', () => {
expect(source).toContain('把 GoNavi 注册成外部 AI 客户端可调用的 MCP Server');
expect(source).toContain('安装到外部客户端');
expect(source).toContain('未安装');
expect(source).toContain('需更新');
expect(source).toContain('已安装');
expect(source).toContain('刷新状态');
expect(source).toContain('复制配置路径');
expect(source).toContain('复制启动命令');
it('delegates bulky MCP and built-in tool sections to dedicated ai components', () => {
expect(source).toContain("import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';");
expect(source).toContain("import AIMCPClientInstallPanel from './ai/AIMCPClientInstallPanel';");
expect(source).toContain("import AIMCPServerCard from './ai/AIMCPServerCard';");
expect(source).toContain('<AIMCPClientInstallPanel');
expect(source).toContain('<AIMCPServerCard');
expect(source).toContain('<AIBuiltinToolsCatalog');
});
it('wires the external MCP client install panel actions back to the modal handlers', () => {
expect(source).toContain('statuses={mcpClientStatuses}');
expect(source).toContain('selectedClient={selectedMCPClient}');
expect(source).toContain('onRefreshStatus={() => void loadMCPClientStatuses()}');
expect(source).toContain('onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}');
expect(source).toContain('onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}');
expect(source).toContain('handleInstallSelectedMCPClient');
expect(source).toContain('无需重复安装');
});
it('waits briefly for the AI service bridge before warning and removes noisy provider debug logs', () => {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AISkillConfig, AISkillScope } from '../types';
import {
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
@@ -22,6 +22,9 @@ import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
import AIMCPClientInstallPanel from './ai/AIMCPClientInstallPanel';
import AIMCPServerCard from './ai/AIMCPServerCard';
interface AISettingsModalProps {
open: boolean;
onClose: () => void;
@@ -219,27 +222,6 @@ const SKILL_SCOPE_OPTIONS: Array<{ value: AISkillScope; label: string; desc: str
{ value: 'jvmDiagnostic', label: 'JVM 诊断', desc: '仅 JVM 诊断工作台启用' },
];
const parseMCPEnvText = (text: string): Record<string, string> => {
const result: Record<string, string> = {};
String(text || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const index = line.indexOf('=');
if (index <= 0) return;
const key = line.slice(0, index).trim();
if (!key) return;
result[key] = line.slice(index + 1);
});
return result;
};
const stringifyMCPEnv = (env?: Record<string, string>): string =>
Object.entries(env || {})
.map(([key, value]) => `${key}=${value}`)
.join('\n');
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
@@ -272,36 +254,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)';
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
const inputBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
const getMCPClientStatusTone = useCallback((status?: AIMCPClientInstallStatus) => {
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return {
label: '已安装',
color: '#16a34a',
bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)',
};
}
if (status?.installed) {
return {
label: '需更新',
color: '#d97706',
bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)',
};
}
if (messageText.includes('失败') || messageText.includes('异常')) {
return {
label: '需检查',
color: '#dc2626',
bg: darkMode ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.1)',
};
}
return {
label: '未安装',
color: darkMode ? 'rgba(255,255,255,0.72)' : '#64748b',
bg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(100,116,139,0.08)',
};
}, [darkMode]);
// Hook 必须在组件顶层调用,不能在条件分支内
const watchedType = Form.useWatch('type', form);
const watchedPresetKey = Form.useWatch('presetKey', form);
@@ -1242,166 +1194,23 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const renderMCPSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4, lineHeight: 1.7 }}>
GoNavi AI MCP Server Claude Code Codex 使 GoNavi
</div>
<div style={{
padding: '16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
display: 'flex',
flexDirection: 'column',
gap: 14,
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
GoNavi MCP GoNavi exe
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
{mcpClientStatuses.map((status) => {
const active = selectedMCPClient === status.client;
const tone = getMCPClientStatusTone(status);
return (
<div
key={status.client}
onClick={() => {
if (status.client === 'claude-code' || status.client === 'codex') {
setSelectedMCPClient(status.client);
}
}}
style={{
padding: '14px 14px 12px',
borderRadius: 12,
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
background: active ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.7)'),
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 10,
transition: 'all 0.2s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}>
{status.displayName}
</div>
<div style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
color: tone.color,
background: tone.bg,
whiteSpace: 'nowrap',
}}>
{tone.label}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{status.matchesCurrent
? '当前 GoNavi 安装路径已写入,打开客户端后可直接使用。'
: status.installed
? '检测到已有安装记录,但建议更新为当前 GoNavi 路径。'
: '当前尚未写入 GoNavi MCP 配置。'}
</div>
</div>
);
})}
</div>
<div style={{
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.78)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{selectedMCPClientStatus?.displayName || '客户端'}
</div>
{selectedMCPClientStatus && (
<div style={{
padding: '3px 9px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: getMCPClientStatusTone(selectedMCPClientStatus).color,
background: getMCPClientStatusTone(selectedMCPClientStatus).bg,
}}>
{getMCPClientStatusTone(selectedMCPClientStatus).label}
</div>
)}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedMCPClientStatus?.message || '未检测到安装状态'}
</div>
{selectedMCPClientStatus?.configPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedMCPClientStatus.configPath}
</div>
)}
{selectedMCPClientCommandText && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedMCPClientCommandText}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button
size="small"
icon={<ReloadOutlined />}
loading={mcpClientStatusLoading}
onClick={() => void loadMCPClientStatuses()}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedMCPClientStatus?.configPath}
onClick={() => void handleCopySelectedMCPConfigPath()}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedMCPClientCommandText}
onClick={() => void handleCopySelectedMCPLaunchCommand()}
style={{ borderRadius: 8 }}
>
</Button>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
</div>
<Button
type={selectedMCPClientStatus?.matchesCurrent ? 'default' : 'primary'}
onClick={handleInstallSelectedMCPClient}
loading={loading}
disabled={Boolean(selectedMCPClientStatus?.matchesCurrent)}
style={{ borderRadius: 10, fontWeight: 600, minWidth: 176, height: 40 }}
>
{selectedMCPClientStatus?.matchesCurrent
? `${selectedMCPClientStatus.displayName} 已安装`
: selectedMCPClientStatus?.installed
? `更新到 ${selectedMCPClientStatus?.displayName || '客户端'}`
: `安装到 ${selectedMCPClientStatus?.displayName || '客户端'}`}
</Button>
</div>
</div>
<AIMCPClientInstallPanel
statuses={mcpClientStatuses}
selectedClient={selectedMCPClient}
selectedStatus={selectedMCPClientStatus}
selectedCommandText={selectedMCPClientCommandText}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
loading={loading}
statusLoading={mcpClientStatusLoading}
onSelectClient={setSelectedMCPClient}
onRefreshStatus={() => void loadMCPClientStatuses()}
onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}
onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}
onInstall={handleInstallSelectedMCPClient}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}> AI </div>
<Button icon={<PlusOutlined />} onClick={handleAddMCPServer} style={{ borderRadius: 10 }}> MCP </Button>
@@ -1411,81 +1220,23 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
MCP `node server.js``uvx some-mcp-server``python -m server`
</div>
)}
{mcpServers.map((server) => {
const serverTools = mcpTools.filter((tool) => tool.serverId === server.id);
return (
<div key={server.id} style={{ padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
<Input
value={server.name}
onChange={(event) => updateMCPServerDraft(server.id, { name: event.target.value })}
placeholder="服务名称例如Filesystem / Browser / GitHub"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Select
value={server.enabled ? 'enabled' : 'disabled'}
onChange={(value) => updateMCPServerDraft(server.id, { enabled: value === 'enabled' })}
options={[{ label: '已启用', value: 'enabled' }, { label: '已禁用', value: 'disabled' }]}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '132px minmax(0,1fr) 132px', gap: 12 }}>
<Select
value={server.transport}
onChange={(value) => updateMCPServerDraft(server.id, { transport: value as AIMCPServerConfig['transport'] })}
options={[{ label: 'stdio', value: 'stdio' }]}
/>
<Input
value={server.command}
onChange={(event) => updateMCPServerDraft(server.id, { command: event.target.value })}
placeholder="启动命令例如node / uvx / python"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Input
type="number"
min={3}
max={120}
value={server.timeoutSeconds}
onChange={(event) => updateMCPServerDraft(server.id, { timeoutSeconds: Number(event.target.value) || 20 })}
placeholder="超时(秒)"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
</div>
<Select
mode="tags"
value={server.args || []}
onChange={(value) => updateMCPServerDraft(server.id, { args: value })}
placeholder="命令参数回车录入例如server.js、--stdio"
style={{ width: '100%' }}
/>
<Input.TextArea
rows={3}
value={stringifyMCPEnv(server.env)}
onChange={(event) => updateMCPServerDraft(server.id, { env: parseMCPEnvText(event.target.value) })}
placeholder={"环境变量,每行一个 KEY=VALUE例如\nOPENAI_API_KEY=...\nGITHUB_TOKEN=..."}
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }}
/>
{serverTools.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{serverTools.map((tool) => (
<span key={tool.alias} style={{ padding: '4px 8px', borderRadius: 999, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', fontSize: 12, color: overlayTheme.mutedText }}>
{tool.alias}
</span>
))}
</div>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => handleTestMCPServer(server)} loading={loading} style={{ borderRadius: 10 }}></Button>
<Button type="primary" onClick={() => handleSaveMCPServer(server)} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}></Button>
<Popconfirm title="删除这个 MCP 服务?" okText="删除" cancelText="取消" onConfirm={() => handleDeleteMCPServer(server.id)}>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}></Button>
</Popconfirm>
</div>
</div>
);
})}
{mcpServers.map((server) => (
<AIMCPServerCard
key={server.id}
server={server}
serverTools={mcpTools.filter((tool) => tool.serverId === server.id)}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
darkMode={darkMode}
overlayTheme={overlayTheme}
loading={loading}
onChange={(patch) => updateMCPServerDraft(server.id, patch)}
onTest={() => handleTestMCPServer(server)}
onSave={() => handleSaveMCPServer(server)}
onDelete={() => handleDeleteMCPServer(server.id)}
/>
))}
</div>
);
@@ -1558,46 +1309,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
</div>
);
const renderBuiltinTools = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
AI
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, padding: '8px 12px', borderRadius: 8, background: cardBg, border: `1px solid ${cardBorder}` }}>
💡 get_connections get_databases get_tables get_columns SQL
</div>
{BUILTIN_AI_TOOL_INFO.map(tool => (
<div key={tool.name} style={{
padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg,
transition: 'all 0.2s ease',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<span style={{ fontSize: 20 }}>{tool.icon}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, fontFamily: 'var(--gn-font-mono)' }}>
{tool.name}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 2 }}>{tool.desc}</div>
</div>
</div>
<div style={{
fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, padding: '8px 12px',
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.02)', borderRadius: 8,
}}>
{tool.detail}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, display: 'flex', alignItems: 'center', gap: 6 }}>
<ToolOutlined style={{ fontSize: 12 }} />
<span></span>
<code style={{ fontFamily: 'var(--gn-font-mono)', fontSize: 12, padding: '1px 6px', borderRadius: 4, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
{tool.params}
</code>
</div>
</div>
))}
</div>
);
const modalShellStyle = {
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
@@ -1683,7 +1394,14 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
{activeSection === 'context' && renderContextSettings()}
{activeSection === 'mcp' && renderMCPSettings()}
{activeSection === 'skills' && renderSkillSettings()}
{activeSection === 'tools' && renderBuiltinTools()}
{activeSection === 'tools' && (
<AIBuiltinToolsCatalog
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
/>
)}
{activeSection === 'prompts' && renderPromptSettings()}
</div>
</div>

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import AIBuiltinToolsCatalog from './AIBuiltinToolsCatalog';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
describe('AIBuiltinToolsCatalog', () => {
it('renders the deep structure analysis flow and the newly added built-in tools', () => {
const markup = renderToStaticMarkup(
<AIBuiltinToolsCatalog
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
/>,
);
expect(markup).toContain('结构深挖');
expect(markup).toContain('get_indexes');
expect(markup).toContain('get_foreign_keys');
expect(markup).toContain('get_triggers');
});
});

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { ToolOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import { BUILTIN_AI_TOOL_INFO } from '../../utils/aiToolRegistry';
interface AIBuiltinToolsCatalogProps {
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
cardBg: string;
cardBorder: string;
}
const BUILTIN_TOOL_FLOWS = [
{
title: '定位表与字段',
steps: 'get_connections → get_databases → get_tables → get_columns',
description: '适合先找连接、找库、找表,再确认真实字段名后生成 SQL。',
},
{
title: '结构深挖',
steps: 'get_columns → get_indexes → get_foreign_keys → get_triggers → get_table_ddl',
description: '适合做索引优化、关系梳理、隐式副作用排查和 DDL 审查。',
},
{
title: '只读验证',
steps: 'get_columns → execute_sql',
description: '适合生成 SQL 后做小范围结果核对,仍会受 AI 安全级别控制。',
},
];
export const AIBuiltinToolsCatalog: React.FC<AIBuiltinToolsCatalogProps> = ({
darkMode,
overlayTheme,
cardBg,
cardBorder,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
AI
</div>
<div style={{ display: 'grid', gap: 8 }}>
{BUILTIN_TOOL_FLOWS.map((flow) => (
<div
key={flow.title}
style={{
fontSize: 12,
color: overlayTheme.mutedText,
padding: '10px 12px',
borderRadius: 10,
background: cardBg,
border: `1px solid ${cardBorder}`,
}}
>
<div style={{ fontWeight: 700, color: overlayTheme.titleText }}>{flow.title}</div>
<div style={{ marginTop: 4, fontFamily: 'var(--gn-font-mono)' }}>{flow.steps}</div>
<div style={{ marginTop: 4, opacity: 0.8, lineHeight: 1.6 }}>{flow.description}</div>
</div>
))}
</div>
{BUILTIN_AI_TOOL_INFO.map((tool) => (
<div
key={tool.name}
style={{
padding: '14px 16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
transition: 'all 0.2s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<span style={{ fontSize: 20 }}>{tool.icon}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, fontFamily: 'var(--gn-font-mono)' }}>
{tool.name}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 2 }}>{tool.desc}</div>
</div>
</div>
<div
style={{
fontSize: 13,
color: overlayTheme.mutedText,
lineHeight: 1.6,
padding: '8px 12px',
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.02)',
borderRadius: 8,
}}
>
{tool.detail}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, display: 'flex', alignItems: 'center', gap: 6 }}>
<ToolOutlined style={{ fontSize: 12 }} />
<span></span>
<code style={{ fontFamily: 'var(--gn-font-mono)', fontSize: 12, padding: '1px 6px', borderRadius: 4, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
{tool.params}
</code>
</div>
</div>
))}
</div>
);
export default AIBuiltinToolsCatalog;

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import AIMCPClientInstallPanel from './AIMCPClientInstallPanel';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
describe('AIMCPClientInstallPanel', () => {
it('renders a clearer external-client selection flow instead of parallel install buttons', () => {
const markup = renderToStaticMarkup(
<AIMCPClientInstallPanel
statuses={[
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未安装到 Claude Code 用户级配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: false,
message: '检测到旧的 Codex 配置,建议更新',
configPath: '~/.codex/config.toml',
command: 'gonavi-mcp-server',
args: ['stdio'],
},
]}
selectedClient="codex"
selectedStatus={{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: false,
message: '检测到旧的 Codex 配置,建议更新',
configPath: '~/.codex/config.toml',
command: 'gonavi-mcp-server',
args: ['stdio'],
}}
selectedCommandText="gonavi-mcp-server stdio"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
loading={false}
statusLoading={false}
onSelectClient={() => {}}
onRefreshStatus={() => {}}
onCopyConfigPath={() => {}}
onCopyLaunchCommand={() => {}}
onInstall={() => {}}
/>,
);
expect(markup).toContain('不是给 GoNavi 自己安装 MCP');
expect(markup).toContain('接入外部客户端');
expect(markup).toContain('目标客户端');
expect(markup).toContain('未接入');
expect(markup).toContain('需更新');
expect(markup).toContain('复制配置路径');
expect(markup).toContain('复制启动命令');
expect(markup).toContain('更新 Codex 配置');
});
});

View File

@@ -0,0 +1,314 @@
import React from 'react';
import { Button } from 'antd';
import { CheckCircleFilled, CopyOutlined, ReloadOutlined } from '@ant-design/icons';
import type { AIMCPClientInstallStatus } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
type MCPClientKey = 'claude-code' | 'codex';
interface AIMCPClientInstallPanelProps {
statuses: AIMCPClientInstallStatus[];
selectedClient: MCPClientKey;
selectedStatus?: AIMCPClientInstallStatus;
selectedCommandText: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
cardBg: string;
cardBorder: string;
loading: boolean;
statusLoading: boolean;
onSelectClient: (client: MCPClientKey) => void;
onRefreshStatus: () => void;
onCopyConfigPath: () => void;
onCopyLaunchCommand: () => void;
onInstall: () => void;
}
const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: boolean) => {
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return {
label: '已接入',
color: '#16a34a',
bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)',
};
}
if (status?.installed) {
return {
label: '需更新',
color: '#d97706',
bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)',
};
}
if (messageText.includes('失败') || messageText.includes('异常')) {
return {
label: '需检查',
color: '#dc2626',
bg: darkMode ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.1)',
};
}
return {
label: '未接入',
color: darkMode ? 'rgba(255,255,255,0.72)' : '#64748b',
bg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(100,116,139,0.08)',
};
};
const getStatusSummary = (status: AIMCPClientInstallStatus | undefined) => {
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return '已经是当前 GoNavi 路径,无需重复接入。';
}
if (status?.installed) {
return '检测到已有 GoNavi 记录,但不是当前路径,建议更新。';
}
if (messageText.includes('失败') || messageText.includes('异常')) {
return '状态读取异常,建议刷新后再检查一次。';
}
return '当前还没有把 GoNavi MCP 写入这个客户端。';
};
const getClientCardDescription = (status: AIMCPClientInstallStatus | undefined) => {
if (status?.matchesCurrent) {
return '当前 GoNavi 路径已经写入,可直接在该客户端中调用。';
}
if (status?.installed) {
return '检测到旧的 GoNavi 记录,建议更新为当前安装路径。';
}
return '尚未写入 GoNavi MCP 配置。';
};
const resolveActionLabel = (status: AIMCPClientInstallStatus | undefined) => {
const label = status?.displayName || '客户端';
if (status?.matchesCurrent) {
return `${label} 已接入`;
}
if (status?.installed) {
return `更新 ${label} 配置`;
}
return `接入 ${label}`;
};
const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
statuses,
selectedClient,
selectedStatus,
selectedCommandText,
darkMode,
overlayTheme,
cardBg,
cardBorder,
loading,
statusLoading,
onSelectClient,
onRefreshStatus,
onCopyConfigPath,
onCopyLaunchCommand,
onInstall,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4, lineHeight: 1.7 }}>
GoNavi MCP GoNavi MCP Server Claude Code Codex AI
</div>
<div
style={{
padding: '16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
GoNavi MCP exe
</div>
</div>
<div
style={{
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.78)',
fontSize: 12,
color: overlayTheme.mutedText,
lineHeight: 1.7,
}}
>
MCP GoNavi GoNavi
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
{statuses.map((status) => {
const client = status.client === 'codex' ? 'codex' : 'claude-code';
const active = selectedClient === client;
const tone = getStatusTone(status, darkMode);
return (
<button
key={status.client}
type="button"
onClick={() => onSelectClient(client)}
style={{
padding: '14px 14px 12px',
borderRadius: 12,
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
background: active ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.7)'),
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 10,
textAlign: 'left',
transition: 'all 0.2s ease',
opacity: statusLoading ? 0.72 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div
aria-hidden
style={{
width: 18,
height: 18,
borderRadius: 999,
border: `1.5px solid ${active ? overlayTheme.selectedText : darkMode ? 'rgba(255,255,255,0.16)' : 'rgba(0,0,0,0.12)'}`,
background: active ? overlayTheme.selectedText : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{active ? <CheckCircleFilled style={{ color: '#fff', fontSize: 12 }} /> : null}
</div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}>
{status.displayName}
</div>
</div>
<div
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
color: tone.color,
background: tone.bg,
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{tone.label}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{getClientCardDescription(status)}
</div>
</button>
);
})}
</div>
</div>
<div
style={{
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.78)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{selectedStatus?.displayName || '客户端'}
</div>
{selectedStatus && (
<div
style={{
padding: '3px 9px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: getStatusTone(selectedStatus, darkMode).color,
background: getStatusTone(selectedStatus, darkMode).bg,
}}
>
{getStatusTone(selectedStatus, darkMode).label}
</div>
)}
</div>
<div style={{ fontSize: 12, color: overlayTheme.titleText, lineHeight: 1.7 }}>
{getStatusSummary(selectedStatus)}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedStatus?.message || '未检测到安装状态'}
</div>
{selectedStatus?.configPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedStatus.configPath}
</div>
)}
{selectedCommandText && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedCommandText}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button
size="small"
icon={<ReloadOutlined />}
loading={statusLoading}
onClick={onRefreshStatus}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedStatus?.configPath}
onClick={onCopyConfigPath}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedCommandText}
onClick={onCopyLaunchCommand}
style={{ borderRadius: 8 }}
>
</Button>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
</div>
<Button
type={selectedStatus?.matchesCurrent ? 'default' : 'primary'}
onClick={onInstall}
loading={loading}
disabled={Boolean(selectedStatus?.matchesCurrent)}
style={{ borderRadius: 10, fontWeight: 600, minWidth: 180, height: 40 }}
>
{resolveActionLabel(selectedStatus)}
</Button>
</div>
</div>
</div>
);
export default AIMCPClientInstallPanel;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import AIMCPServerCard from './AIMCPServerCard';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
describe('AIMCPServerCard', () => {
it('renders explicit MCP parameter hints for command, args, and env', () => {
const markup = renderToStaticMarkup(
<AIMCPServerCard
server={{
id: 'mcp-1',
name: '',
transport: 'stdio',
command: '',
args: [],
env: {},
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('每个参数单独录入一个标签');
expect(markup).toContain('每行一个 KEY=VALUE');
expect(markup).toContain('当前阶段只支持 stdio');
});
});

View File

@@ -0,0 +1,191 @@
import React from 'react';
import { Button, Input, Popconfirm, Select } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types';
interface AIMCPServerCardProps {
server: AIMCPServerConfig;
serverTools: AIMCPToolDescriptor[];
cardBg: string;
cardBorder: string;
inputBg: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
loading: boolean;
onChange: (patch: Partial<AIMCPServerConfig>) => void;
onTest: () => void;
onSave: () => void;
onDelete: () => void;
}
const labelStyle: React.CSSProperties = {
fontSize: 12,
fontWeight: 700,
};
const hintStyle = (mutedText: string): React.CSSProperties => ({
fontSize: 12,
color: mutedText,
lineHeight: 1.6,
});
const MCP_COMMAND_EXAMPLES = [
'uvx mcp-server-fetch',
'node server.js --stdio',
'python -m your_mcp_server',
];
const MCPHelpBlock: React.FC<{
title: string;
description: string;
overlayTheme: OverlayWorkbenchTheme;
example?: string;
children: React.ReactNode;
}> = ({ title, description, overlayTheme, example, children }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={labelStyle}>{title}</div>
<div style={hintStyle(overlayTheme.mutedText)}>
{description}
{example ? (
<>
{' '}<code style={{ fontFamily: 'var(--gn-font-mono)' }}>{example}</code>
</>
) : null}
</div>
{children}
</div>
);
export const AIMCPServerCard: React.FC<AIMCPServerCardProps> = ({
server,
serverTools,
cardBg,
cardBorder,
inputBg,
darkMode,
overlayTheme,
loading,
onChange,
onTest,
onSave,
onDelete,
}) => (
<div style={{ padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: '10px 12px', borderRadius: 10, border: `1px dashed ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.7)' }}>
<div style={{ ...labelStyle, color: overlayTheme.titleText }}></div>
<div style={{ ...hintStyle(overlayTheme.mutedText), marginTop: 4 }}>
{' '}
<code style={{ fontFamily: 'var(--gn-font-mono)' }}>{MCP_COMMAND_EXAMPLES.join(' / ')}</code>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
<MCPHelpBlock title="服务名称" description="给这个 MCP 起一个你自己能识别的名字,后面 AI 工具列表里会直接显示。" overlayTheme={overlayTheme} example="Filesystem / Browser / GitHub">
<Input
value={server.name}
onChange={(event) => onChange({ name: event.target.value })}
placeholder="服务名称例如Filesystem / Browser / GitHub"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
</MCPHelpBlock>
<MCPHelpBlock title="启用状态" description="临时不用可以先禁用,保留配置但不参与 AI 工具发现。" overlayTheme={overlayTheme}>
<Select
value={server.enabled ? 'enabled' : 'disabled'}
onChange={(value) => onChange({ enabled: value === 'enabled' })}
options={[{ label: '已启用', value: 'enabled' }, { label: '已禁用', value: 'disabled' }]}
/>
</MCPHelpBlock>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '132px minmax(0,1fr) 132px', gap: 12 }}>
<MCPHelpBlock title="传输方式" description="当前阶段只支持 stdio表示 GoNavi 会在本机启动这个进程,并通过标准输入输出与它通信。" overlayTheme={overlayTheme}>
<Select
value={server.transport}
onChange={(value) => onChange({ transport: value as AIMCPServerConfig['transport'] })}
options={[{ label: 'stdio', value: 'stdio' }]}
/>
</MCPHelpBlock>
<MCPHelpBlock title="启动命令" description="这里只填命令本身;如果是 node/uvx/python 这类启动器,把脚本名或模块名放到下面的参数里。" overlayTheme={overlayTheme} example="node / uvx / python">
<Input
value={server.command}
onChange={(event) => onChange({ command: event.target.value })}
placeholder="启动命令例如node / uvx / python"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
</MCPHelpBlock>
<MCPHelpBlock title="超时(秒)" description="工具发现和工具调用单次最多等多久。远端服务或启动慢的脚本可以适当调大。" overlayTheme={overlayTheme} example="20">
<Input
type="number"
min={3}
max={120}
value={server.timeoutSeconds}
onChange={(event) => onChange({ timeoutSeconds: Number(event.target.value) || 20 })}
placeholder="超时(秒)"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
</MCPHelpBlock>
</div>
<MCPHelpBlock title="命令参数" description="每个参数单独录入一个标签;命令本体不要填在这里。比如 node server.js --stdio要把 server.js 和 --stdio 分开填。" overlayTheme={overlayTheme} example="server.js、--stdio、-m、your_mcp_server">
<Select
mode="tags"
value={server.args || []}
onChange={(value) => onChange({ args: value })}
placeholder="命令参数回车录入例如server.js、--stdio"
style={{ width: '100%' }}
/>
</MCPHelpBlock>
<MCPHelpBlock title="环境变量" description="每行一个 KEY=VALUE通常用于 API Key、工作目录、服务地址等配置不需要时可以留空。" overlayTheme={overlayTheme} example="OPENAI_API_KEY=...">
<Input.TextArea
rows={3}
value={Object.entries(server.env || {}).map(([key, value]) => `${key}=${value}`).join('\n')}
onChange={(event) => onChange({
env: event.target.value
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.reduce<Record<string, string>>((acc, line) => {
const separatorIndex = line.indexOf('=');
if (separatorIndex <= 0) {
return acc;
}
const key = line.slice(0, separatorIndex).trim();
if (!key) {
return acc;
}
acc[key] = line.slice(separatorIndex + 1);
return acc;
}, {}),
})}
placeholder={"环境变量,每行一个 KEY=VALUE例如\nOPENAI_API_KEY=...\nGITHUB_TOKEN=..."}
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }}
/>
</MCPHelpBlock>
{serverTools.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{serverTools.map((tool) => (
<span key={tool.alias} style={{ padding: '4px 8px', borderRadius: 999, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', fontSize: 12, color: overlayTheme.mutedText }}>
{tool.alias}
</span>
))}
</div>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={onTest} loading={loading} style={{ borderRadius: 10 }}></Button>
<Button type="primary" onClick={onSave} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}></Button>
<Popconfirm title="删除这个 MCP 服务?" okText="删除" cancelText="取消" onConfirm={onDelete}>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}></Button>
</Popconfirm>
</div>
</div>
);
export default AIMCPServerCard;

View File

@@ -496,6 +496,12 @@ const AIToolCallingBlock: React.FC<{ tool_calls: AIToolCall[]; loading: boolean;
if (fname === 'get_connections') return '获取可用连接信息';
if (fname === 'get_databases') return '扫描数据库列表';
if (fname === 'get_tables') return '分析表结构信息';
if (fname === 'get_columns') return '核对真实字段定义';
if (fname === 'get_indexes') return '检查索引定义';
if (fname === 'get_foreign_keys') return '梳理外键关系';
if (fname === 'get_triggers') return '检查触发器逻辑';
if (fname === 'get_table_ddl') return '提取建表语句';
if (fname === 'execute_sql') return '执行只读 SQL 验证';
return fname;
};

View File

@@ -106,6 +106,81 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "get_indexes",
icon: "🧭",
desc: "获取指定表的索引定义",
detail:
"传入 connectionId、dbName 和 tableName返回索引名、索引列、唯一性和索引类型。AI 在做慢 SQL 分析、索引优化和执行计划推断时应优先调用。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_indexes",
description:
"获取指定表的索引定义,包括索引名、字段顺序、唯一性和索引类型。适用于慢 SQL 分析、索引优化建议和确认现有索引覆盖情况。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "get_foreign_keys",
icon: "🧬",
desc: "获取指定表的外键关系",
detail:
"传入 connectionId、dbName 和 tableName返回当前表到其他表的外键映射。AI 在推断表关系、生成联表 SQL 和评审数据一致性时可直接使用。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_foreign_keys",
description:
"获取指定表的外键关系包括本表字段、引用表、引用字段和约束名。适用于联表路径分析、ER 关系梳理和约束检查。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "get_triggers",
icon: "⏱️",
desc: "获取指定表的触发器定义",
detail:
"传入 connectionId、dbName 和 tableName返回触发器名、触发时机、事件类型和语句体。AI 在分析隐式写入、副作用和审计逻辑时可直接查看。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_triggers",
description:
"获取指定表的触发器定义,包括触发时机、事件和触发语句。适用于排查隐式数据变更、审计逻辑和表级副作用。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "get_table_ddl",
icon: "📝",

View File

@@ -1320,3 +1320,4 @@ export namespace sync {
}
}

View File

@@ -19,6 +19,9 @@ type Backend interface {
DBGetDatabases(config connection.ConnectionConfig) connection.QueryResult
DBGetTables(config connection.ConnectionConfig, dbName string) connection.QueryResult
DBGetColumns(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
DBGetIndexes(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
DBQueryMulti(config connection.ConnectionConfig, dbName string, query string, queryID string) connection.QueryResult
InspectSQL(dbType string, sql string) appcore.SQLInspection
@@ -70,6 +73,18 @@ func (b *AppBackend) DBGetColumns(config connection.ConnectionConfig, dbName str
return b.app.DBGetColumns(config, dbName, tableName)
}
func (b *AppBackend) DBGetIndexes(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
return b.app.DBGetIndexes(config, dbName, tableName)
}
func (b *AppBackend) DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
return b.app.DBGetForeignKeys(config, dbName, tableName)
}
func (b *AppBackend) DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
return b.app.DBGetTriggers(config, dbName, tableName)
}
func (b *AppBackend) DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
return b.app.DBShowCreateTable(config, dbName, tableName)
}

View File

@@ -35,6 +35,21 @@ func NewServer(backend Backend) *mcp.Server {
Description: "根据 connectionId、可选 dbName、tableName 获取字段定义。",
}, service.GetColumns)
mcp.AddTool(server, &mcp.Tool{
Name: "get_indexes",
Description: "根据 connectionId、可选 dbName、tableName 获取索引定义。",
}, service.GetIndexes)
mcp.AddTool(server, &mcp.Tool{
Name: "get_foreign_keys",
Description: "根据 connectionId、可选 dbName、tableName 获取外键关系。",
}, service.GetForeignKeys)
mcp.AddTool(server, &mcp.Tool{
Name: "get_triggers",
Description: "根据 connectionId、可选 dbName、tableName 获取触发器定义。",
}, service.GetTriggers)
mcp.AddTool(server, &mcp.Tool{
Name: "get_table_ddl",
Description: "根据 connectionId、可选 dbName、tableName 获取建表或建视图语句。",

View File

@@ -88,6 +88,27 @@ type getColumnsResult struct {
Columns []connection.ColumnDefinition `json:"columns"`
}
type getIndexesResult struct {
ConnectionID string `json:"connectionId"`
DBName string `json:"dbName,omitempty"`
TableName string `json:"tableName"`
Indexes []connection.IndexDefinition `json:"indexes"`
}
type getForeignKeysResult struct {
ConnectionID string `json:"connectionId"`
DBName string `json:"dbName,omitempty"`
TableName string `json:"tableName"`
ForeignKeys []connection.ForeignKeyDefinition `json:"foreignKeys"`
}
type getTriggersResult struct {
ConnectionID string `json:"connectionId"`
DBName string `json:"dbName,omitempty"`
TableName string `json:"tableName"`
Triggers []connection.TriggerDefinition `json:"triggers"`
}
type getTableDDLResult struct {
ConnectionID string `json:"connectionId"`
DBName string `json:"dbName,omitempty"`
@@ -241,6 +262,105 @@ func (s *Service) GetColumns(ctx context.Context, req *mcp.CallToolRequest, args
}, nil
}
func (s *Service) GetIndexes(ctx context.Context, req *mcp.CallToolRequest, args tableArgs) (*mcp.CallToolResult, getIndexesResult, error) {
_ = ctx
_ = req
view, errResult := s.resolveConnection(args.ConnectionID)
if errResult != nil {
return errResult, getIndexesResult{}, nil
}
tableName := strings.TrimSpace(args.TableName)
if tableName == "" {
return toolError("tableName 不能为空"), getIndexesResult{}, nil
}
dbName := effectiveDBName(args.DBName, view.Config)
queryResult := s.backend.DBGetIndexes(view.Config, dbName, tableName)
if !queryResult.Success {
return toolError("获取索引定义失败: %s", strings.TrimSpace(queryResult.Message)), getIndexesResult{}, nil
}
indexes, err := decodeIndexes(queryResult.Data)
if err != nil {
return toolError("解析索引定义失败: %v", err), getIndexesResult{}, nil
}
return successResult(), getIndexesResult{
ConnectionID: view.ID,
DBName: dbName,
TableName: tableName,
Indexes: ensureNonNilIndexes(indexes),
}, nil
}
func (s *Service) GetForeignKeys(ctx context.Context, req *mcp.CallToolRequest, args tableArgs) (*mcp.CallToolResult, getForeignKeysResult, error) {
_ = ctx
_ = req
view, errResult := s.resolveConnection(args.ConnectionID)
if errResult != nil {
return errResult, getForeignKeysResult{}, nil
}
tableName := strings.TrimSpace(args.TableName)
if tableName == "" {
return toolError("tableName 不能为空"), getForeignKeysResult{}, nil
}
dbName := effectiveDBName(args.DBName, view.Config)
queryResult := s.backend.DBGetForeignKeys(view.Config, dbName, tableName)
if !queryResult.Success {
return toolError("获取外键关系失败: %s", strings.TrimSpace(queryResult.Message)), getForeignKeysResult{}, nil
}
foreignKeys, err := decodeForeignKeys(queryResult.Data)
if err != nil {
return toolError("解析外键关系失败: %v", err), getForeignKeysResult{}, nil
}
return successResult(), getForeignKeysResult{
ConnectionID: view.ID,
DBName: dbName,
TableName: tableName,
ForeignKeys: ensureNonNilForeignKeys(foreignKeys),
}, nil
}
func (s *Service) GetTriggers(ctx context.Context, req *mcp.CallToolRequest, args tableArgs) (*mcp.CallToolResult, getTriggersResult, error) {
_ = ctx
_ = req
view, errResult := s.resolveConnection(args.ConnectionID)
if errResult != nil {
return errResult, getTriggersResult{}, nil
}
tableName := strings.TrimSpace(args.TableName)
if tableName == "" {
return toolError("tableName 不能为空"), getTriggersResult{}, nil
}
dbName := effectiveDBName(args.DBName, view.Config)
queryResult := s.backend.DBGetTriggers(view.Config, dbName, tableName)
if !queryResult.Success {
return toolError("获取触发器定义失败: %s", strings.TrimSpace(queryResult.Message)), getTriggersResult{}, nil
}
triggers, err := decodeTriggers(queryResult.Data)
if err != nil {
return toolError("解析触发器定义失败: %v", err), getTriggersResult{}, nil
}
return successResult(), getTriggersResult{
ConnectionID: view.ID,
DBName: dbName,
TableName: tableName,
Triggers: ensureNonNilTriggers(triggers),
}, nil
}
func (s *Service) GetTableDDL(ctx context.Context, req *mcp.CallToolRequest, args tableArgs) (*mcp.CallToolResult, getTableDDLResult, error) {
_ = ctx
_ = req
@@ -457,6 +577,51 @@ func decodeColumns(data interface{}) ([]connection.ColumnDefinition, error) {
}
}
func decodeIndexes(data interface{}) ([]connection.IndexDefinition, error) {
switch indexes := data.(type) {
case nil:
return []connection.IndexDefinition{}, nil
case []connection.IndexDefinition:
return ensureNonNilIndexes(append([]connection.IndexDefinition(nil), indexes...)), nil
default:
var decoded []connection.IndexDefinition
if err := remarshal(data, &decoded); err != nil {
return nil, err
}
return ensureNonNilIndexes(decoded), nil
}
}
func decodeForeignKeys(data interface{}) ([]connection.ForeignKeyDefinition, error) {
switch foreignKeys := data.(type) {
case nil:
return []connection.ForeignKeyDefinition{}, nil
case []connection.ForeignKeyDefinition:
return ensureNonNilForeignKeys(append([]connection.ForeignKeyDefinition(nil), foreignKeys...)), nil
default:
var decoded []connection.ForeignKeyDefinition
if err := remarshal(data, &decoded); err != nil {
return nil, err
}
return ensureNonNilForeignKeys(decoded), nil
}
}
func decodeTriggers(data interface{}) ([]connection.TriggerDefinition, error) {
switch triggers := data.(type) {
case nil:
return []connection.TriggerDefinition{}, nil
case []connection.TriggerDefinition:
return ensureNonNilTriggers(append([]connection.TriggerDefinition(nil), triggers...)), nil
default:
var decoded []connection.TriggerDefinition
if err := remarshal(data, &decoded); err != nil {
return nil, err
}
return ensureNonNilTriggers(decoded), nil
}
}
func decodeString(data interface{}) (string, error) {
switch value := data.(type) {
case nil:
@@ -551,6 +716,27 @@ func ensureNonNilColumns(items []connection.ColumnDefinition) []connection.Colum
return items
}
func ensureNonNilIndexes(items []connection.IndexDefinition) []connection.IndexDefinition {
if items == nil {
return []connection.IndexDefinition{}
}
return items
}
func ensureNonNilForeignKeys(items []connection.ForeignKeyDefinition) []connection.ForeignKeyDefinition {
if items == nil {
return []connection.ForeignKeyDefinition{}
}
return items
}
func ensureNonNilTriggers(items []connection.TriggerDefinition) []connection.TriggerDefinition {
if items == nil {
return []connection.TriggerDefinition{}
}
return items
}
func ensureNonNilRows(items []map[string]interface{}) []map[string]interface{} {
if items == nil {
return []map[string]interface{}{}

View File

@@ -19,6 +19,9 @@ type fakeBackend struct {
databasesResult connection.QueryResult
tablesResult connection.QueryResult
columnsResult connection.QueryResult
indexesResult connection.QueryResult
foreignKeysResult connection.QueryResult
triggersResult connection.QueryResult
ddlResult connection.QueryResult
queryResult connection.QueryResult
inspection appcore.SQLInspection
@@ -50,6 +53,18 @@ func (f *fakeBackend) DBGetColumns(config connection.ConnectionConfig, dbName st
return f.columnsResult
}
func (f *fakeBackend) DBGetIndexes(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
return f.indexesResult
}
func (f *fakeBackend) DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
return f.foreignKeysResult
}
func (f *fakeBackend) DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
return f.triggersResult
}
func (f *fakeBackend) DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
return f.ddlResult
}
@@ -114,6 +129,108 @@ func TestGetConnectionsReturnsSavedConnectionSummaries(t *testing.T) {
}
}
func TestGetIndexesReturnsIndexDefinitions(t *testing.T) {
backend := &fakeBackend{
editableConnection: connection.SavedConnectionView{
ID: "mysql-main",
Config: connection.ConnectionConfig{
Type: "mysql",
Database: "app",
},
},
indexesResult: connection.QueryResult{
Success: true,
Data: []connection.IndexDefinition{
{Name: "idx_users_email", ColumnName: "email", NonUnique: 0, SeqInIndex: 1, IndexType: "BTREE"},
},
},
}
service := NewService(backend)
result, out, err := service.GetIndexes(context.Background(), nil, tableArgs{
ConnectionID: "mysql-main",
DBName: "app",
TableName: "users",
})
if err != nil {
t.Fatalf("GetIndexes returned error: %v", err)
}
if result == nil || result.IsError {
t.Fatalf("expected success result, got %#v", result)
}
if len(out.Indexes) != 1 || out.Indexes[0].Name != "idx_users_email" {
t.Fatalf("unexpected indexes output: %#v", out)
}
}
func TestGetForeignKeysReturnsForeignKeyDefinitions(t *testing.T) {
backend := &fakeBackend{
editableConnection: connection.SavedConnectionView{
ID: "mysql-main",
Config: connection.ConnectionConfig{
Type: "mysql",
Database: "app",
},
},
foreignKeysResult: connection.QueryResult{
Success: true,
Data: []connection.ForeignKeyDefinition{
{Name: "fk_orders_user_id", ColumnName: "user_id", RefTableName: "users", RefColumnName: "id", ConstraintName: "fk_orders_user_id"},
},
},
}
service := NewService(backend)
result, out, err := service.GetForeignKeys(context.Background(), nil, tableArgs{
ConnectionID: "mysql-main",
DBName: "app",
TableName: "orders",
})
if err != nil {
t.Fatalf("GetForeignKeys returned error: %v", err)
}
if result == nil || result.IsError {
t.Fatalf("expected success result, got %#v", result)
}
if len(out.ForeignKeys) != 1 || out.ForeignKeys[0].RefTableName != "users" {
t.Fatalf("unexpected foreign keys output: %#v", out)
}
}
func TestGetTriggersReturnsTriggerDefinitions(t *testing.T) {
backend := &fakeBackend{
editableConnection: connection.SavedConnectionView{
ID: "mysql-main",
Config: connection.ConnectionConfig{
Type: "mysql",
Database: "app",
},
},
triggersResult: connection.QueryResult{
Success: true,
Data: []connection.TriggerDefinition{
{Name: "trg_orders_audit", Timing: "AFTER", Event: "INSERT", Statement: "INSERT INTO audit_log ..."},
},
},
}
service := NewService(backend)
result, out, err := service.GetTriggers(context.Background(), nil, tableArgs{
ConnectionID: "mysql-main",
DBName: "app",
TableName: "orders",
})
if err != nil {
t.Fatalf("GetTriggers returned error: %v", err)
}
if result == nil || result.IsError {
t.Fatalf("expected success result, got %#v", result)
}
if len(out.Triggers) != 1 || out.Triggers[0].Name != "trg_orders_audit" {
t.Fatalf("unexpected triggers output: %#v", out)
}
}
func TestExecuteSQLRejectsMutatingStatementsWithoutAllowMutating(t *testing.T) {
backend := &fakeBackend{
editableConnection: connection.SavedConnectionView{