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

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

View File

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

View File

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

View File

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

View File

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