feat(ai-settings): 优化MCP录入引导并补充结构快照工具

- 新增完整命令自动拆分与提示词设置分区,降低 MCP 配置门槛

- 新增 inspect_table_bundle 内置工具并补充状态文案

- 补齐定向测试、前端构建与预览验证
This commit is contained in:
Syngnat
2026-06-08 08:24:27 +08:00
parent 7d7b775fe0
commit e1cebb1c9a
14 changed files with 622 additions and 108 deletions

View File

@@ -14,7 +14,8 @@ describe('AISettingsModal edit password behavior', () => {
expect(source).toContain("callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS)");
expect(source).toContain('await Service?.AISaveUserPromptSettings?.(payload);');
expect(source).toContain("window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'))");
expect(source).toContain('保存自定义提示词');
expect(source).toContain("import AISettingsPromptsSection from './ai/AISettingsPromptsSection';");
expect(source).toContain('<AISettingsPromptsSection');
});
it('loads MCP servers and skills through the AI service', () => {

View File

@@ -24,6 +24,7 @@ import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSection';
import AISettingsPromptsSection from './ai/AISettingsPromptsSection';
import AISettingsSkillsSection from './ai/AISettingsSkillsSection';
interface AISettingsModalProps {
open: boolean;
@@ -1096,104 +1097,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
</div>
);
const renderPromptSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{
padding: '14px 16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
}}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 6 }}>
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 14 }}>
system message
线
</div>
{[
{
key: 'global',
title: '全局补充提示词',
desc: '对所有 AI 会话生效,例如“先给结论”“回答保持简洁”。',
rows: 4,
},
{
key: 'database',
title: '数据库会话补充提示词',
desc: '仅数据库/SQL 场景生效,例如“生成 SQL 前必须先确认字段名”。',
rows: 5,
},
{
key: 'jvm',
title: 'JVM 资源分析补充提示词',
desc: '仅 JVM 资源浏览/分析场景生效。',
rows: 4,
},
{
key: 'jvmDiagnostic',
title: 'JVM 诊断补充提示词',
desc: '仅 JVM 诊断工作台生效,例如“先给计划,再给命令”。',
rows: 4,
},
].map((item) => (
<div key={item.key} style={{ marginTop: 14 }}>
<div style={{ fontWeight: 600, fontSize: 13, color: overlayTheme.titleText, marginBottom: 4 }}>
{item.title}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
{item.desc}
</div>
<Input.TextArea
rows={item.rows}
value={userPromptSettings[item.key as keyof AIUserPromptSettings]}
onChange={(event) => setUserPromptSettings((prev) => ({
...prev,
[item.key]: event.target.value,
}))}
placeholder="留空表示不额外追加"
style={{
borderRadius: 10,
background: inputBg,
border: `1px solid ${cardBorder}`,
fontFamily: 'var(--gn-font-mono)',
resize: 'vertical',
}}
/>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<Button type="primary" onClick={handleSaveUserPromptSettings} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}>
</Button>
</div>
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
GoNavi AI
</div>
{Object.entries(builtinPrompts).map(([title, promptText]) => (
<div key={title} style={{
padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: cardBg,
}}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<RobotOutlined style={{ color: overlayTheme.iconColor }} /> {title}
</div>
<div style={{
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)',
padding: '10px 12px', borderRadius: 8, fontSize: 13, color: overlayTheme.mutedText,
whiteSpace: 'pre-wrap', fontFamily: 'var(--gn-font-mono)', lineHeight: 1.5,
userSelect: 'text', border: darkMode ? '1px solid rgba(255,255,255,0.03)' : '1px solid rgba(0,0,0,0.02)'
}}>
{promptText}
</div>
</div>
))}
</div>
);
const modalShellStyle = {
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
@@ -1327,7 +1230,23 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
cardBorder={cardBorder}
/>
)}
{activeSection === 'prompts' && renderPromptSettings()}
{activeSection === 'prompts' && (
<AISettingsPromptsSection
builtinPrompts={builtinPrompts}
userPromptSettings={userPromptSettings}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
darkMode={darkMode}
loading={loading}
onChangeUserPrompt={(key, value) => setUserPromptSettings((prev) => ({
...prev,
[key]: value,
}))}
onSave={handleSaveUserPromptSettings}
/>
)}
</div>
</div>
</Modal>

View File

@@ -22,6 +22,8 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('get_indexes');
expect(markup).toContain('get_foreign_keys');
expect(markup).toContain('get_triggers');
expect(markup).toContain('一键结构快照');
expect(markup).toContain('inspect_table_bundle');
expect(markup).toContain('理解样例数据');
expect(markup).toContain('preview_table_rows');
});

View File

@@ -27,6 +27,11 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'get_columns → get_indexes → get_foreign_keys → get_triggers → get_table_ddl',
description: '适合做索引优化、关系梳理、隐式副作用排查和 DDL 审查。',
},
{
title: '一键结构快照',
steps: 'inspect_table_bundle',
description: '适合一次带回字段、索引、外键、触发器和 DDL必要时还能附带样例行减少来回调用。',
},
{
title: '理解样例数据',
steps: 'get_columns → preview_table_rows',

View File

@@ -34,6 +34,8 @@ describe('AIMCPServerCard', () => {
);
expect(markup).toContain('启动命令只填可执行程序本身');
expect(markup).toContain('直接粘贴完整命令');
expect(markup).toContain('自动拆分到下方字段');
expect(markup).toContain('每个参数单独录入一个标签');
expect(markup).toContain('每行一个 KEY=VALUE');
expect(markup).toContain('当前阶段只支持 stdio');

View File

@@ -4,6 +4,7 @@ import { DeleteOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types';
import { parseMCPCommandDraft } from '../../utils/mcpCommandDraft';
interface AIMCPServerCardProps {
server: AIMCPServerConfig;
@@ -86,7 +87,20 @@ export const AIMCPServerCard: React.FC<AIMCPServerCardProps> = ({
onSave,
onDelete,
}) => {
const [rawCommandDraft, setRawCommandDraft] = React.useState('');
const launchPreview = formatLaunchPreview(server.command, server.args);
const parsedCommandDraft = parseMCPCommandDraft(rawCommandDraft);
const handleApplyCommandDraft = () => {
if (!parsedCommandDraft.ok || !parsedCommandDraft.draft) {
return;
}
onChange({
command: parsedCommandDraft.draft.command,
args: parsedCommandDraft.draft.args,
env: parsedCommandDraft.draft.env,
});
};
return (
<div style={{ padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg, display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -99,6 +113,32 @@ export const AIMCPServerCard: React.FC<AIMCPServerCardProps> = ({
</div>
</div>
<div style={{ padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ ...labelStyle, color: overlayTheme.titleText }}></div>
<div style={hintStyle(overlayTheme.mutedText)}>
GoNavi / / README
</div>
<Input.TextArea
rows={2}
value={rawCommandDraft}
onChange={(event) => setRawCommandDraft(event.target.value)}
placeholder={"直接粘贴完整命令,例如:\nOPENAI_API_KEY=... uvx mcp-server-fetch --stdio"}
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ ...hintStyle(parsedCommandDraft.ok ? overlayTheme.mutedText : '#dc2626') }}>
{rawCommandDraft.trim()
? parsedCommandDraft.ok && parsedCommandDraft.draft
? `将解析为:命令 ${parsedCommandDraft.draft.command},参数 ${parsedCommandDraft.draft.args.length} 个,环境变量 ${Object.keys(parsedCommandDraft.draft.env).length} 个。`
: parsedCommandDraft.error
: '支持带引号路径、带空格参数,以及命令前缀的 KEY=VALUE 环境变量。'}
</div>
<Button onClick={handleApplyCommandDraft} disabled={!parsedCommandDraft.ok} style={{ borderRadius: 10 }}>
</Button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
<MCPHelpBlock title="服务名称" description="给这个 MCP 起一个你自己能识别的名字,后面 AI 工具列表里会直接显示。" overlayTheme={overlayTheme} example="Filesystem / Browser / GitHub">
<Input

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import AISettingsPromptsSection from './AISettingsPromptsSection';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
describe('AISettingsPromptsSection', () => {
it('renders editable user prompts and readonly builtin prompt blocks after extraction', () => {
const markup = renderToStaticMarkup(
<AISettingsPromptsSection
builtinPrompts={{ : '生成 SQL 前必须先确认字段名。' }}
userPromptSettings={{
global: '',
database: '',
jvm: '',
jvmDiagnostic: '',
}}
overlayTheme={buildOverlayWorkbenchTheme(false)}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
inputBg="#fff"
darkMode={false}
loading={false}
onChangeUserPrompt={() => {}}
onSave={() => {}}
/>,
);
expect(markup).toContain('用户级自定义提示词');
expect(markup).toContain('全局补充提示词');
expect(markup).toContain('保存自定义提示词');
expect(markup).toContain('数据库');
expect(markup).toContain('生成 SQL 前必须先确认字段名');
});
});

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { Button, Input } from 'antd';
import { RobotOutlined } from '@ant-design/icons';
import type { AIUserPromptSettings } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
interface AISettingsPromptsSectionProps {
builtinPrompts: Record<string, string>;
userPromptSettings: AIUserPromptSettings;
overlayTheme: OverlayWorkbenchTheme;
cardBg: string;
cardBorder: string;
inputBg: string;
darkMode: boolean;
loading: boolean;
onChangeUserPrompt: (key: keyof AIUserPromptSettings, value: string) => void;
onSave: () => void;
}
const USER_PROMPT_FIELDS: Array<{
key: keyof AIUserPromptSettings;
title: string;
desc: string;
rows: number;
}> = [
{
key: 'global',
title: '全局补充提示词',
desc: '对所有 AI 会话生效,例如“先给结论”“回答保持简洁”。',
rows: 4,
},
{
key: 'database',
title: '数据库会话补充提示词',
desc: '仅数据库/SQL 场景生效,例如“生成 SQL 前必须先确认字段名”。',
rows: 5,
},
{
key: 'jvm',
title: 'JVM 资源分析补充提示词',
desc: '仅 JVM 资源浏览/分析场景生效。',
rows: 4,
},
{
key: 'jvmDiagnostic',
title: 'JVM 诊断补充提示词',
desc: '仅 JVM 诊断工作台生效,例如“先给计划,再给命令”。',
rows: 4,
},
];
const AISettingsPromptsSection: React.FC<AISettingsPromptsSectionProps> = ({
builtinPrompts,
userPromptSettings,
overlayTheme,
cardBg,
cardBorder,
inputBg,
darkMode,
loading,
onChangeUserPrompt,
onSave,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div
style={{
padding: '14px 16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
}}
>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 6 }}>
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 14 }}>
system message
线
</div>
{USER_PROMPT_FIELDS.map((item) => (
<div key={item.key} style={{ marginTop: 14 }}>
<div style={{ fontWeight: 600, fontSize: 13, color: overlayTheme.titleText, marginBottom: 4 }}>
{item.title}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
{item.desc}
</div>
<Input.TextArea
rows={item.rows}
value={userPromptSettings[item.key]}
onChange={(event) => onChangeUserPrompt(item.key, event.target.value)}
placeholder="留空表示不额外追加"
style={{
borderRadius: 10,
background: inputBg,
border: `1px solid ${cardBorder}`,
fontFamily: 'var(--gn-font-mono)',
resize: 'vertical',
}}
/>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<Button type="primary" onClick={onSave} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}>
</Button>
</div>
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
GoNavi AI
</div>
{Object.entries(builtinPrompts).map(([title, promptText]) => (
<div
key={title}
style={{
padding: '12px',
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: cardBg,
}}
>
<div
style={{
fontWeight: 700,
fontSize: 14,
color: overlayTheme.titleText,
marginBottom: 8,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<RobotOutlined style={{ color: overlayTheme.iconColor }} /> {title}
</div>
<div
style={{
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)',
padding: '10px 12px',
borderRadius: 8,
fontSize: 13,
color: overlayTheme.mutedText,
whiteSpace: 'pre-wrap',
fontFamily: 'var(--gn-font-mono)',
lineHeight: 1.5,
userSelect: 'text',
border: darkMode ? '1px solid rgba(255,255,255,0.03)' : '1px solid rgba(0,0,0,0.02)',
}}
>
{promptText}
</div>
</div>
))}
</div>
);
export default AISettingsPromptsSection;

View File

@@ -193,4 +193,55 @@ describe('aiLocalToolExecutor', () => {
expect(result.content).toContain('"status":"paid"');
expect(result.content).toContain('"rowCount":2');
});
it('returns a full table snapshot bundle with optional sample rows in one tool call', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_table_bundle', {
connectionId: 'conn-1',
dbName: 'crm',
tableName: 'orders',
includeSampleRows: true,
sampleLimit: 2,
}),
connections: [buildConnection()],
mcpTools: [],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
getColumns: vi.fn().mockResolvedValue({
success: true,
data: [{ Field: 'id', Type: 'bigint', Null: 'NO', Comment: '主键' }],
}),
getIndexes: vi.fn().mockResolvedValue({
success: true,
data: [{ keyName: 'PRIMARY', seqInIndex: 1 }],
}),
getForeignKeys: vi.fn().mockResolvedValue({
success: true,
data: [{ columnName: 'user_id', refTable: 'users' }],
}),
getTriggers: vi.fn().mockResolvedValue({
success: true,
data: [{ triggerName: 'orders_bi' }],
}),
showCreateTable: vi.fn().mockResolvedValue({
success: true,
data: [{ ddl: 'CREATE TABLE orders (...)' }],
}),
query: vi.fn().mockResolvedValue({
success: true,
data: [{ id: 1, status: 'paid' }, { id: 2, status: 'pending' }],
}),
},
});
expect(result.success).toBe(true);
expect(result.content).toContain('"tableName":"orders"');
expect(result.content).toContain('"field":"id"');
expect(result.content).toContain('"keyName":"PRIMARY"');
expect(result.content).toContain('"triggerName":"orders_bi"');
expect(result.content).toContain('"sampleRows"');
expect(result.content).toContain('"status":"paid"');
});
});

View File

@@ -123,6 +123,17 @@ const normalizePreviewLimit = (input: unknown): number => {
return value;
};
const buildPreviewSQLForTable = (connection: SavedConnection, tableName: string, limit: number): string => {
const dbType = String(connection.config?.type || '').trim();
return buildPaginatedSelectSQL(
dbType,
`SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)}`,
'',
limit,
0,
);
};
export async function executeLocalAIToolCall({
toolCall,
connections,
@@ -339,6 +350,109 @@ export async function executeLocalAIToolCall({
}
break;
}
case 'inspect_table_bundle': {
const connection = findConnection(connections, args.connectionId);
if (!connection) {
content = 'Connection not found';
break;
}
try {
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
if (!safeTable) {
content = 'tableName 不能为空';
break;
}
const includeSampleRows = args.includeSampleRows === true;
const sampleLimit = normalizePreviewLimit(args.sampleLimit ?? 10);
const rpcConfig = buildRpcConnectionConfig(connection.config) as any;
const results = await Promise.allSettled([
mergedRuntime.getColumns(rpcConfig, safeDbName, safeTable),
mergedRuntime.getIndexes(rpcConfig, safeDbName, safeTable),
mergedRuntime.getForeignKeys(rpcConfig, safeDbName, safeTable),
mergedRuntime.getTriggers(rpcConfig, safeDbName, safeTable),
resolveAITableSchemaToolResult({
tableName: safeTable,
fetchDDL: () => mergedRuntime.showCreateTable(rpcConfig, safeDbName, safeTable),
fetchColumns: () => mergedRuntime.getColumns(rpcConfig, safeDbName, safeTable),
}),
includeSampleRows
? mergedRuntime.query(rpcConfig, safeDbName, buildPreviewSQLForTable(connection, safeTable, sampleLimit))
: Promise.resolve(undefined),
]);
const warnings: string[] = [];
const columnsResult = results[0];
const indexesResult = results[1];
const foreignKeysResult = results[2];
const triggersResult = results[3];
const ddlResult = results[4];
const sampleRowsResult = results[5];
const payload: Record<string, unknown> = {
dbName: safeDbName,
tableName: safeTable,
columns: [],
indexes: [],
foreignKeys: [],
triggers: [],
ddl: '',
};
if (columnsResult.status === 'fulfilled' && columnsResult.value?.success && Array.isArray(columnsResult.value.data)) {
payload.columns = normalizeColumns(columnsResult.value.data);
} else {
warnings.push(`字段列表获取失败:${columnsResult.status === 'fulfilled' ? (columnsResult.value?.message || '未知错误') : String(columnsResult.reason)}`);
}
if (indexesResult.status === 'fulfilled' && indexesResult.value?.success && Array.isArray(indexesResult.value.data)) {
payload.indexes = indexesResult.value.data;
} else {
warnings.push(`索引定义获取失败:${indexesResult.status === 'fulfilled' ? (indexesResult.value?.message || '未知错误') : String(indexesResult.reason)}`);
}
if (foreignKeysResult.status === 'fulfilled' && foreignKeysResult.value?.success && Array.isArray(foreignKeysResult.value.data)) {
payload.foreignKeys = foreignKeysResult.value.data;
} else {
warnings.push(`外键关系获取失败:${foreignKeysResult.status === 'fulfilled' ? (foreignKeysResult.value?.message || '未知错误') : String(foreignKeysResult.reason)}`);
}
if (triggersResult.status === 'fulfilled' && triggersResult.value?.success && Array.isArray(triggersResult.value.data)) {
payload.triggers = triggersResult.value.data;
} else {
warnings.push(`触发器获取失败:${triggersResult.status === 'fulfilled' ? (triggersResult.value?.message || '未知错误') : String(triggersResult.reason)}`);
}
if (ddlResult.status === 'fulfilled' && ddlResult.value?.success) {
payload.ddl = ddlResult.value.content;
} else {
warnings.push(`DDL 获取失败:${ddlResult.status === 'fulfilled' ? (ddlResult.value?.content || '未知错误') : String(ddlResult.reason)}`);
}
if (includeSampleRows) {
if (sampleRowsResult.status === 'fulfilled' && sampleRowsResult.value?.success) {
const rows = Array.isArray(sampleRowsResult.value.data) ? sampleRowsResult.value.data : [];
payload.sampleRows = {
limit: sampleLimit,
rowCount: rows.length,
rows: rows.slice(0, sampleLimit),
};
} else {
warnings.push(`样例数据获取失败:${sampleRowsResult.status === 'fulfilled' ? (sampleRowsResult.value?.message || '未知错误') : String(sampleRowsResult.reason)}`);
}
}
if (warnings.length > 0) {
payload.warnings = warnings;
}
content = JSON.stringify(payload);
success = true;
} catch (error: any) {
content = `获取表结构快照失败: ${error?.message || error}`;
}
break;
}
case 'preview_table_rows': {
const connection = findConnection(connections, args.connectionId);
if (!connection) {
@@ -353,14 +467,7 @@ export async function executeLocalAIToolCall({
break;
}
const safeLimit = normalizePreviewLimit(args.limit);
const dbType = String(connection.config?.type || '').trim();
const previewSQL = buildPaginatedSelectSQL(
dbType,
`SELECT * FROM ${quoteQualifiedIdent(dbType, safeTable)}`,
'',
safeLimit,
0,
);
const previewSQL = buildPreviewSQLForTable(connection, safeTable, safeLimit);
const result = await mergedRuntime.query(buildRpcConnectionConfig(connection.config) as any, safeDbName, previewSQL);
if (result?.success) {
const rows = Array.isArray(result.data) ? result.data : [];

View File

@@ -31,6 +31,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
get_foreign_keys: '梳理外键关系',
get_triggers: '检查触发器逻辑',
get_table_ddl: '提取建表语句',
inspect_table_bundle: '抓取完整表结构快照',
execute_sql: '执行只读 SQL 验证',
};

View File

@@ -255,6 +255,33 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_table_bundle",
icon: "🧰",
desc: "一次抓取指定表的结构快照",
detail:
"传入 connectionId、dbName 和 tableName返回字段、索引、外键、触发器和 DDL还可以附带前几行样例数据。适合在写 SQL、评审表设计或排查副作用前先做完整摸底。",
params: "connectionId, dbName, tableName, includeSampleRows?, sampleLimit?",
tool: {
type: "function",
function: {
name: "inspect_table_bundle",
description:
"一次性获取指定表的结构快照返回字段、索引、外键、触发器、DDL以及可选样例数据。适用于做完整表设计摸底、快速理解表关系和降低模型多次往返调用。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
includeSampleRows: { type: "boolean", description: "可选,是否附带前几行样例数据" },
sampleLimit: { type: "number", description: "可选,样例行数,默认 10最大 100" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "execute_sql",
icon: "▶️",

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { parseMCPCommandDraft, splitShellLikeCommand } from './mcpCommandDraft';
describe('mcpCommandDraft helpers', () => {
it('splits quoted command lines and leading env assignments into dedicated fields', () => {
const result = parseMCPCommandDraft('OPENAI_API_KEY="abc 123" "C:\\Program Files\\GoNavi\\gonavi-mcp-server.exe" stdio --port 8811');
expect(result).toEqual({
ok: true,
draft: {
command: 'C:\\Program Files\\GoNavi\\gonavi-mcp-server.exe',
args: ['stdio', '--port', '8811'],
env: {
OPENAI_API_KEY: 'abc 123',
},
},
});
});
it('keeps python module style launches as command plus independent args', () => {
const result = parseMCPCommandDraft('PYTHONPATH=./tools python -m my_mcp_server --stdio');
expect(result.ok).toBe(true);
expect(result.draft).toEqual({
command: 'python',
args: ['-m', 'my_mcp_server', '--stdio'],
env: {
PYTHONPATH: './tools',
},
});
});
it('reports unclosed quotes instead of producing a broken parse', () => {
expect(splitShellLikeCommand('uvx "broken command')).toEqual({
tokens: ['uvx'],
error: '命令中存在未闭合的引号,请检查后重试。',
});
});
});

View File

@@ -0,0 +1,123 @@
export interface ParsedMCPCommandDraft {
command: string;
args: string[];
env: Record<string, string>;
}
export interface ParseMCPCommandDraftResult {
ok: boolean;
draft?: ParsedMCPCommandDraft;
error?: string;
}
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=.*/u;
const pushToken = (tokens: string[], current: string) => {
if (current) {
tokens.push(current);
}
};
export const splitShellLikeCommand = (input: string): { tokens: string[]; error?: string } => {
const text = String(input || '').trim();
if (!text) {
return { tokens: [] };
}
const tokens: string[] = [];
let current = '';
let quoteMode: '"' | "'" | null = null;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoteMode) {
if (char === quoteMode) {
quoteMode = null;
continue;
}
if (char === '\\' && quoteMode === '"' && index + 1 < text.length) {
const nextChar = text[index + 1];
if (nextChar === '"' || nextChar === '\\') {
current += nextChar;
index += 1;
continue;
}
}
current += char;
continue;
}
if (char === '"' || char === "'") {
quoteMode = char;
continue;
}
if (/\s/u.test(char)) {
pushToken(tokens, current);
current = '';
continue;
}
if (char === '\\' && index + 1 < text.length) {
const nextChar = text[index + 1];
if (/\s/u.test(nextChar) || nextChar === '"' || nextChar === "'" || nextChar === '\\') {
current += nextChar;
index += 1;
continue;
}
}
current += char;
}
if (quoteMode) {
return {
tokens,
error: '命令中存在未闭合的引号,请检查后重试。',
};
}
pushToken(tokens, current);
return { tokens };
};
export const parseMCPCommandDraft = (input: string): ParseMCPCommandDraftResult => {
const { tokens, error } = splitShellLikeCommand(input);
if (error) {
return { ok: false, error };
}
if (tokens.length === 0) {
return { ok: false, error: '请先粘贴完整命令。' };
}
const env: Record<string, string> = {};
let commandIndex = 0;
while (commandIndex < tokens.length && ENV_ASSIGNMENT_RE.test(tokens[commandIndex])) {
const token = tokens[commandIndex];
const separatorIndex = token.indexOf('=');
const key = token.slice(0, separatorIndex).trim();
if (key) {
env[key] = token.slice(separatorIndex + 1);
}
commandIndex += 1;
}
const command = String(tokens[commandIndex] || '').trim();
if (!command) {
return {
ok: false,
error: '没有解析出启动命令,请至少提供可执行程序名。',
};
}
return {
ok: true,
draft: {
command,
args: tokens.slice(commandIndex + 1),
env,
},
};
};