feat(ai-mcp): 补全跨表字段探针并拆分 MCP 设置区块

- 新增 get_all_columns 内置工具和 MCP server 只读探针
- MCP 服务表单增加实际启动命令预览并补强参数提示
- 抽离 AISettingsMCPSection 并补齐前后端测试
This commit is contained in:
Syngnat
2026-06-08 07:29:52 +08:00
parent aad0f447c0
commit 6c53fb14a6
15 changed files with 564 additions and 178 deletions

View File

@@ -22,26 +22,23 @@ describe('AISettingsModal edit password behavior', () => {
expect(source).toContain('Service.AIGetMCPServers?.()');
expect(source).toContain('Service.AIListMCPTools?.()');
expect(source).toContain('Service.AIGetSkills?.()');
expect(source).toContain('新增 MCP 服务');
expect(source).toContain('新增 Skill');
});
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("import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSection';");
expect(source).toContain('<AISettingsMCPSection');
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('mcpClientStatuses={mcpClientStatuses}');
expect(source).toContain('selectedMCPClient={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('onInstallSelectedClient={handleInstallSelectedMCPClient}');
});
it('waits briefly for the AI service bridge before warning and removes noisy provider debug logs', () => {

View File

@@ -23,8 +23,7 @@ import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildE
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';
import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSection';
interface AISettingsModalProps {
open: boolean;
onClose: () => void;
@@ -42,8 +41,6 @@ interface MCPClientInstallResult {
args?: string[];
}
type MCPClientKey = 'claude-code' | 'codex';
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
interface ProviderPreset {
key: string;
@@ -1192,54 +1189,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
</div>
);
const renderMCPSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<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>
</div>
{mcpServers.length === 0 && (
<div style={{ padding: '18px 16px', borderRadius: 14, border: `1px dashed ${cardBorder}`, background: cardBg, color: overlayTheme.mutedText }}>
MCP `node server.js``uvx some-mcp-server``python -m server`
</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>
);
const renderSkillSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
@@ -1392,7 +1341,33 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
{activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())}
{activeSection === 'safety' && renderSafetySettings()}
{activeSection === 'context' && renderContextSettings()}
{activeSection === 'mcp' && renderMCPSettings()}
{activeSection === 'mcp' && (
<AISettingsMCPSection
mcpClientStatuses={mcpClientStatuses}
selectedMCPClient={selectedMCPClient}
selectedMCPClientStatus={selectedMCPClientStatus}
selectedMCPClientCommandText={selectedMCPClientCommandText}
mcpServers={mcpServers}
mcpTools={mcpTools}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
loading={loading}
mcpClientStatusLoading={mcpClientStatusLoading}
onSelectClient={setSelectedMCPClient}
onRefreshStatus={() => void loadMCPClientStatuses()}
onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}
onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}
onInstallSelectedClient={handleInstallSelectedMCPClient}
onAddServer={handleAddMCPServer}
onUpdateServerDraft={updateMCPServerDraft}
onTestServer={handleTestMCPServer}
onSaveServer={handleSaveMCPServer}
onDeleteServer={handleDeleteMCPServer}
/>
)}
{activeSection === 'skills' && renderSkillSettings()}
{activeSection === 'tools' && (
<AIBuiltinToolsCatalog

View File

@@ -6,7 +6,7 @@ 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', () => {
it('renders the field-to-table flow and the deeper structure analysis tools', () => {
const markup = renderToStaticMarkup(
<AIBuiltinToolsCatalog
darkMode={false}
@@ -16,6 +16,8 @@ describe('AIBuiltinToolsCatalog', () => {
/>,
);
expect(markup).toContain('字段反查表');
expect(markup).toContain('get_all_columns');
expect(markup).toContain('结构深挖');
expect(markup).toContain('get_indexes');
expect(markup).toContain('get_foreign_keys');

View File

@@ -17,6 +17,11 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'get_connections → get_databases → get_tables → get_columns',
description: '适合先找连接、找库、找表,再确认真实字段名后生成 SQL。',
},
{
title: '字段反查表',
steps: 'get_databases → get_all_columns',
description: '适合只知道字段名、业务含义或注释关键词,但还不确定具体落在哪张表。',
},
{
title: '结构深挖',
steps: 'get_columns → get_indexes → get_foreign_keys → get_triggers → get_table_ddl',

View File

@@ -6,15 +6,15 @@ import AIMCPServerCard from './AIMCPServerCard';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
describe('AIMCPServerCard', () => {
it('renders explicit MCP parameter hints for command, args, and env', () => {
it('renders explicit MCP parameter hints and the actual launch preview for command, args, and env', () => {
const markup = renderToStaticMarkup(
<AIMCPServerCard
server={{
id: 'mcp-1',
name: '',
transport: 'stdio',
command: '',
args: [],
command: 'node',
args: ['server.js', '--stdio'],
env: {},
enabled: true,
timeoutSeconds: 20,
@@ -37,5 +37,7 @@ describe('AIMCPServerCard', () => {
expect(markup).toContain('每个参数单独录入一个标签');
expect(markup).toContain('每行一个 KEY=VALUE');
expect(markup).toContain('当前阶段只支持 stdio');
expect(markup).toContain('实际启动命令预览');
expect(markup).toContain('node server.js --stdio');
});
});

View File

@@ -37,6 +37,20 @@ const MCP_COMMAND_EXAMPLES = [
'python -m your_mcp_server',
];
const quoteCommandPart = (value: string): string => {
const text = String(value || '').trim();
if (!text) {
return '';
}
return /[\s"]/u.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
};
const formatLaunchPreview = (command: string, args?: string[]): string =>
[command, ...(Array.isArray(args) ? args : [])]
.map((item) => quoteCommandPart(item))
.filter(Boolean)
.join(' ');
const MCPHelpBlock: React.FC<{
title: string;
description: string;
@@ -71,121 +85,137 @@ export const AIMCPServerCard: React.FC<AIMCPServerCardProps> = ({
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>
}) => {
const launchPreview = formatLaunchPreview(server.command, server.args);
<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>
))}
return (
<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: '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 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>
{launchPreview && (
<div style={{ padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)' }}>
<div style={{ ...labelStyle, color: overlayTheme.titleText }}></div>
<div style={{ ...hintStyle(overlayTheme.mutedText), marginTop: 4 }}>
GoNavi 便
</div>
<code style={{ display: 'block', marginTop: 8, fontFamily: 'var(--gn-font-mono)', fontSize: 12, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{launchPreview}
</code>
</div>
)}
<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>
</div>
);
);
};
export default AIMCPServerCard;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import AISettingsMCPSection from './AISettingsMCPSection';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
describe('AISettingsMCPSection', () => {
it('renders the extracted MCP client installer and server management entry point', () => {
const markup = renderToStaticMarkup(
<AISettingsMCPSection
mcpClientStatuses={[
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未安装到 Claude Code 用户级配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: false,
matchesCurrent: false,
message: '未安装到 Codex 用户级配置',
},
]}
selectedMCPClient="claude-code"
selectedMCPClientStatus={{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未安装到 Claude Code 用户级配置',
}}
selectedMCPClientCommandText=""
mcpServers={[]}
mcpTools={[]}
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
inputBg="#fff"
loading={false}
mcpClientStatusLoading={false}
onSelectClient={() => {}}
onRefreshStatus={() => {}}
onCopyConfigPath={() => {}}
onCopyLaunchCommand={() => {}}
onInstallSelectedClient={() => {}}
onAddServer={() => {}}
onUpdateServerDraft={() => {}}
onTestServer={() => {}}
onSaveServer={() => {}}
onDeleteServer={() => {}}
/>,
);
expect(markup).toContain('接入外部客户端');
expect(markup).toContain('新增 MCP 服务');
expect(markup).toContain('还没有 MCP 服务');
});
});

View File

@@ -0,0 +1,110 @@
import React from 'react';
import { Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { AIMCPClientInstallStatus, AIMCPServerConfig, AIMCPToolDescriptor } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPClientInstallPanel from './AIMCPClientInstallPanel';
import AIMCPServerCard from './AIMCPServerCard';
export type MCPClientKey = 'claude-code' | 'codex';
interface AISettingsMCPSectionProps {
mcpClientStatuses: AIMCPClientInstallStatus[];
selectedMCPClient: MCPClientKey;
selectedMCPClientStatus?: AIMCPClientInstallStatus;
selectedMCPClientCommandText: string;
mcpServers: AIMCPServerConfig[];
mcpTools: AIMCPToolDescriptor[];
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
cardBg: string;
cardBorder: string;
inputBg: string;
loading: boolean;
mcpClientStatusLoading: boolean;
onSelectClient: (client: MCPClientKey) => void;
onRefreshStatus: () => void;
onCopyConfigPath: () => void;
onCopyLaunchCommand: () => void;
onInstallSelectedClient: () => void;
onAddServer: () => void;
onUpdateServerDraft: (id: string, patch: Partial<AIMCPServerConfig>) => void;
onTestServer: (server: AIMCPServerConfig) => void;
onSaveServer: (server: AIMCPServerConfig) => void;
onDeleteServer: (id: string) => void;
}
const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
mcpClientStatuses,
selectedMCPClient,
selectedMCPClientStatus,
selectedMCPClientCommandText,
mcpServers,
mcpTools,
darkMode,
overlayTheme,
cardBg,
cardBorder,
inputBg,
loading,
mcpClientStatusLoading,
onSelectClient,
onRefreshStatus,
onCopyConfigPath,
onCopyLaunchCommand,
onInstallSelectedClient,
onAddServer,
onUpdateServerDraft,
onTestServer,
onSaveServer,
onDeleteServer,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<AIMCPClientInstallPanel
statuses={mcpClientStatuses}
selectedClient={selectedMCPClient}
selectedStatus={selectedMCPClientStatus}
selectedCommandText={selectedMCPClientCommandText}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
loading={loading}
statusLoading={mcpClientStatusLoading}
onSelectClient={onSelectClient}
onRefreshStatus={onRefreshStatus}
onCopyConfigPath={onCopyConfigPath}
onCopyLaunchCommand={onCopyLaunchCommand}
onInstall={onInstallSelectedClient}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}> AI </div>
<Button icon={<PlusOutlined />} onClick={onAddServer} style={{ borderRadius: 10 }}> MCP </Button>
</div>
{mcpServers.length === 0 && (
<div style={{ padding: '18px 16px', borderRadius: 14, border: `1px dashed ${cardBorder}`, background: cardBg, color: overlayTheme.mutedText }}>
MCP `node server.js``uvx some-mcp-server``python -m server`
</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) => onUpdateServerDraft(server.id, patch)}
onTest={() => onTestServer(server)}
onSave={() => onSaveServer(server)}
onDelete={() => onDeleteServer(server.id)}
/>
))}
</div>
);
export default AISettingsMCPSection;

View File

@@ -81,6 +81,34 @@ describe('aiLocalToolExecutor', () => {
expect(query).not.toHaveBeenCalled();
});
it('returns a cross-table column summary for get_all_columns', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('get_all_columns', {
connectionId: 'conn-1',
dbName: 'crm',
}),
connections: [buildConnection()],
mcpTools: [],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
getAllColumns: vi.fn().mockResolvedValue({
success: true,
data: [
{ TableName: 'users', Name: 'email', Type: 'varchar(255)', Comment: '用户邮箱' },
{ TableName: 'orders', Name: 'user_id', Type: 'bigint', Comment: '关联用户' },
],
}),
},
});
expect(result.success).toBe(true);
expect(result.content).toContain('"tableCount":2');
expect(result.content).toContain('"tableName":"users"');
expect(result.content).toContain('"name":"email"');
});
it('returns index definitions and resolves the tool label for MCP descriptors', async () => {
const mcpTools: AIMCPToolDescriptor[] = [{
alias: 'custom_tool',

View File

@@ -1,4 +1,4 @@
import { DBGetDatabases, DBGetTables } from '../../../wailsjs/go/app/App';
import { DBGetAllColumns, DBGetDatabases, DBGetTables } from '../../../wailsjs/go/app/App';
import type { AIChatMessage, AIMCPToolDescriptor, AIToolCall, SavedConnection } from '../../types';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
@@ -14,6 +14,7 @@ export interface AIToolContextEntry {
interface AILocalToolRuntime {
getDatabases: (config: any) => Promise<any>;
getTables: (config: any, dbName: string) => Promise<any>;
getAllColumns: (config: any, dbName: string) => Promise<any>;
getColumns: (config: any, dbName: string, tableName: string) => Promise<any>;
getIndexes: (config: any, dbName: string, tableName: string) => Promise<any>;
getForeignKeys: (config: any, dbName: string, tableName: string) => Promise<any>;
@@ -41,6 +42,7 @@ export interface ExecuteLocalAIToolCallResult {
const buildDefaultRuntime = (): AILocalToolRuntime => ({
getDatabases: DBGetDatabases,
getTables: DBGetTables,
getAllColumns: DBGetAllColumns,
getColumns: async (config, dbName, tableName) => {
const mod = await import('../../../wailsjs/go/app/App');
return mod.DBGetColumns(config, dbName, tableName);
@@ -96,6 +98,17 @@ const normalizeColumns = (rows: any[]) =>
};
});
const normalizeColumnsWithTable = (rows: any[]) =>
rows.map((column) => {
const keys = Object.keys(column);
return {
tableName: column.TableName || column.tableName || column.TABLE_NAME || column.table_name || (keys.length > 0 ? column[keys[0]] : ''),
name: column.Name || column.name || column.COLUMN_NAME || column.column_name || (keys.length > 1 ? column[keys[1]] : ''),
type: column.Type || column.type || column.DATA_TYPE || column.data_type || (keys.length > 2 ? column[keys[2]] : ''),
comment: column.Comment || column.comment || column.COLUMN_COMMENT || column.column_comment || '',
};
});
const buildToolName = (toolCall: AIToolCall, descriptor?: AIMCPToolDescriptor) =>
descriptor?.title || descriptor?.originalName || toolCall.function.name;
@@ -181,6 +194,35 @@ export async function executeLocalAIToolCall({
}
break;
}
case 'get_all_columns': {
const connection = findConnection(connections, args.connectionId);
if (!connection) {
content = 'Connection not found';
break;
}
try {
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const result = await mergedRuntime.getAllColumns(buildRpcConnectionConfig(connection.config) as any, safeDbName);
if (result?.success && Array.isArray(result.data)) {
const allColumns = normalizeColumnsWithTable(result.data);
const tableNames = Array.from(new Set(allColumns.map((column) => column.tableName).filter(Boolean)));
const limitedColumns = allColumns.slice(0, 400);
content = JSON.stringify({
dbName: safeDbName,
tableCount: tableNames.length,
totalColumns: allColumns.length,
truncated: allColumns.length > limitedColumns.length,
columns: limitedColumns,
});
success = true;
} else {
content = result?.message || 'Failed to fetch all columns';
}
} catch (error: any) {
content = `获取全库字段摘要失败: ${error?.message || error}`;
}
break;
}
case 'get_columns': {
const connection = findConnection(connections, args.connectionId);
if (!connection) {

View File

@@ -81,6 +81,30 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "get_all_columns",
icon: "🧱",
desc: "获取指定数据库下所有表的字段摘要",
detail:
"传入 connectionId 和 dbName返回跨表字段列表表名、字段名、类型、注释。适合用户只知道业务字段、不知道具体在哪张表时快速定位目标表。",
params: "connectionId, dbName",
tool: {
type: "function",
function: {
name: "get_all_columns",
description:
"获取指定数据库下全部表的字段摘要,返回表名、字段名、类型和注释。适用于按字段反查表、跨表梳理相同字段、做数据地图探索。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
},
required: ["connectionId", "dbName"],
},
},
},
},
{
name: "get_columns",
icon: "🔍",

View File

@@ -18,6 +18,7 @@ type Backend interface {
GetEditableSavedConnection(id string) (connection.SavedConnectionView, error)
DBGetDatabases(config connection.ConnectionConfig) connection.QueryResult
DBGetTables(config connection.ConnectionConfig, dbName string) connection.QueryResult
DBGetAllColumns(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
@@ -69,6 +70,10 @@ func (b *AppBackend) DBGetTables(config connection.ConnectionConfig, dbName stri
return b.app.DBGetTables(config, dbName)
}
func (b *AppBackend) DBGetAllColumns(config connection.ConnectionConfig, dbName string) connection.QueryResult {
return b.app.DBGetAllColumns(config, dbName)
}
func (b *AppBackend) DBGetColumns(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
return b.app.DBGetColumns(config, dbName, tableName)
}

View File

@@ -30,6 +30,11 @@ func NewServer(backend Backend) *mcp.Server {
Description: "根据 connectionId 和可选 dbName 获取表列表。dbName 为空时优先使用保存连接里的默认数据库。",
}, service.GetTables)
mcp.AddTool(server, &mcp.Tool{
Name: "get_all_columns",
Description: "根据 connectionId 和 dbName 获取该数据库下全部表的字段摘要,适合按字段反查表。",
}, service.GetAllColumns)
mcp.AddTool(server, &mcp.Tool{
Name: "get_columns",
Description: "根据 connectionId、可选 dbName、tableName 获取字段定义。",

View File

@@ -81,6 +81,12 @@ type getTablesResult struct {
Tables []string `json:"tables"`
}
type getAllColumnsResult struct {
ConnectionID string `json:"connectionId"`
DBName string `json:"dbName,omitempty"`
Columns []connection.ColumnDefinitionWithTable `json:"columns"`
}
type getColumnsResult struct {
ConnectionID string `json:"connectionId"`
DBName string `json:"dbName,omitempty"`
@@ -229,6 +235,37 @@ func (s *Service) GetTables(ctx context.Context, req *mcp.CallToolRequest, args
}, nil
}
func (s *Service) GetAllColumns(ctx context.Context, req *mcp.CallToolRequest, args databaseArgs) (*mcp.CallToolResult, getAllColumnsResult, error) {
_ = ctx
_ = req
view, errResult := s.resolveConnection(args.ConnectionID)
if errResult != nil {
return errResult, getAllColumnsResult{}, nil
}
dbName := effectiveDBName(args.DBName, view.Config)
if strings.TrimSpace(dbName) == "" {
return toolError("dbName 不能为空"), getAllColumnsResult{}, nil
}
queryResult := s.backend.DBGetAllColumns(view.Config, dbName)
if !queryResult.Success {
return toolError("获取全库字段摘要失败: %s", strings.TrimSpace(queryResult.Message)), getAllColumnsResult{}, nil
}
columns, err := decodeColumnsWithTable(queryResult.Data)
if err != nil {
return toolError("解析全库字段摘要失败: %v", err), getAllColumnsResult{}, nil
}
return successResult(), getAllColumnsResult{
ConnectionID: view.ID,
DBName: dbName,
Columns: ensureNonNilColumnsWithTable(columns),
}, nil
}
func (s *Service) GetColumns(ctx context.Context, req *mcp.CallToolRequest, args tableArgs) (*mcp.CallToolResult, getColumnsResult, error) {
_ = ctx
_ = req
@@ -577,6 +614,21 @@ func decodeColumns(data interface{}) ([]connection.ColumnDefinition, error) {
}
}
func decodeColumnsWithTable(data interface{}) ([]connection.ColumnDefinitionWithTable, error) {
switch cols := data.(type) {
case nil:
return []connection.ColumnDefinitionWithTable{}, nil
case []connection.ColumnDefinitionWithTable:
return ensureNonNilColumnsWithTable(append([]connection.ColumnDefinitionWithTable(nil), cols...)), nil
default:
var decoded []connection.ColumnDefinitionWithTable
if err := remarshal(data, &decoded); err != nil {
return nil, err
}
return ensureNonNilColumnsWithTable(decoded), nil
}
}
func decodeIndexes(data interface{}) ([]connection.IndexDefinition, error) {
switch indexes := data.(type) {
case nil:
@@ -716,6 +768,13 @@ func ensureNonNilColumns(items []connection.ColumnDefinition) []connection.Colum
return items
}
func ensureNonNilColumnsWithTable(items []connection.ColumnDefinitionWithTable) []connection.ColumnDefinitionWithTable {
if items == nil {
return []connection.ColumnDefinitionWithTable{}
}
return items
}
func ensureNonNilIndexes(items []connection.IndexDefinition) []connection.IndexDefinition {
if items == nil {
return []connection.IndexDefinition{}

View File

@@ -18,6 +18,7 @@ type fakeBackend struct {
editableErr error
databasesResult connection.QueryResult
tablesResult connection.QueryResult
allColumnsResult connection.QueryResult
columnsResult connection.QueryResult
indexesResult connection.QueryResult
foreignKeysResult connection.QueryResult
@@ -49,6 +50,10 @@ func (f *fakeBackend) DBGetTables(config connection.ConnectionConfig, dbName str
return f.tablesResult
}
func (f *fakeBackend) DBGetAllColumns(config connection.ConnectionConfig, dbName string) connection.QueryResult {
return f.allColumnsResult
}
func (f *fakeBackend) DBGetColumns(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
return f.columnsResult
}
@@ -129,6 +134,40 @@ func TestGetConnectionsReturnsSavedConnectionSummaries(t *testing.T) {
}
}
func TestGetAllColumnsReturnsCrossTableColumnSummaries(t *testing.T) {
backend := &fakeBackend{
editableConnection: connection.SavedConnectionView{
ID: "mysql-main",
Config: connection.ConnectionConfig{
Type: "mysql",
Database: "app",
},
},
allColumnsResult: connection.QueryResult{
Success: true,
Data: []connection.ColumnDefinitionWithTable{
{TableName: "users", Name: "email", Type: "varchar(255)", Comment: "用户邮箱"},
{TableName: "orders", Name: "user_id", Type: "bigint", Comment: "关联用户"},
},
},
}
service := NewService(backend)
result, out, err := service.GetAllColumns(context.Background(), nil, databaseArgs{
ConnectionID: "mysql-main",
DBName: "app",
})
if err != nil {
t.Fatalf("GetAllColumns returned error: %v", err)
}
if result == nil || result.IsError {
t.Fatalf("expected success result, got %#v", result)
}
if len(out.Columns) != 2 || out.Columns[0].TableName != "users" || out.Columns[1].Name != "user_id" {
t.Fatalf("unexpected all columns output: %#v", out)
}
}
func TestGetIndexesReturnsIndexDefinitions(t *testing.T) {
backend := &fakeBackend{
editableConnection: connection.SavedConnectionView{