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