mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
✨ feat(ai-mcp): 补全跨表字段探针并拆分 MCP 设置区块
- 新增 get_all_columns 内置工具和 MCP server 只读探针 - MCP 服务表单增加实际启动命令预览并补强参数提示 - 抽离 AISettingsMCPSection 并补齐前后端测试
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
// 预设配置:每个预设映射到后端 type(openai/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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
63
frontend/src/components/ai/AISettingsMCPSection.test.tsx
Normal file
63
frontend/src/components/ai/AISettingsMCPSection.test.tsx
Normal 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 服务');
|
||||
});
|
||||
});
|
||||
110
frontend/src/components/ai/AISettingsMCPSection.tsx
Normal file
110
frontend/src/components/ai/AISettingsMCPSection.tsx
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: "🔍",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 获取字段定义。",
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user