diff --git a/backend/docs/docs.go b/backend/docs/docs.go index bbc029f0..521ab10f 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -3784,6 +3784,12 @@ const docTemplate = `{ "name": "id", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "是否用远端元数据覆盖管理员自定义的工具名称和说明", + "name": "overwrite_customized_metadata", + "in": "query" } ], "responses": { @@ -19217,6 +19223,7 @@ const docTemplate = `{ "lastError", "lastSyncedAt", "name", + "requiresToolMetadataSyncConfirmation", "sortOrder", "status", "toolCount", @@ -19249,6 +19256,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "requiresToolMetadataSyncConfirmation": { + "type": "boolean" + }, "sortOrder": { "type": "integer" }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 3b8b3eeb..6aa71471 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -3777,6 +3777,12 @@ "name": "id", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "是否用远端元数据覆盖管理员自定义的工具名称和说明", + "name": "overwrite_customized_metadata", + "in": "query" } ], "responses": { @@ -19210,6 +19216,7 @@ "lastError", "lastSyncedAt", "name", + "requiresToolMetadataSyncConfirmation", "sortOrder", "status", "toolCount", @@ -19242,6 +19249,9 @@ "name": { "type": "string" }, + "requiresToolMetadataSyncConfirmation": { + "type": "boolean" + }, "sortOrder": { "type": "integer" }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index f4f98e6b..f7fb89fb 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -5613,6 +5613,8 @@ definitions: x-omitempty: false name: type: string + requiresToolMetadataSyncConfirmation: + type: boolean sortOrder: type: integer status: @@ -5630,6 +5632,7 @@ definitions: - lastError - lastSyncedAt - name + - requiresToolMetadataSyncConfirmation - sortOrder - status - toolCount @@ -10261,6 +10264,10 @@ paths: name: id required: true type: integer + - description: 是否用远端元数据覆盖管理员自定义的工具名称和说明 + in: query + name: overwrite_customized_metadata + type: boolean produces: - application/json responses: diff --git a/backend/internal/application/mcp/service.go b/backend/internal/application/mcp/service.go index 14fb9848..296eb894 100644 --- a/backend/internal/application/mcp/service.go +++ b/backend/internal/application/mcp/service.go @@ -63,8 +63,9 @@ type ToolInput struct { // SyncServerToolsInput 描述一次 MCP 工具同步请求。 type SyncServerToolsInput struct { - ServerID uint - RequestID string + ServerID uint + RequestID string + OverwriteCustomizedMetadata bool } // NewServiceWithRuntime 创建 MCP 应用服务。 @@ -190,7 +191,7 @@ func (s *Service) SyncServerTools(ctx context.Context, input SyncServerToolsInpu Status: "active", }) } - if err = s.repo.ReplaceServerTools(ctx, serverID, items); err != nil { + if err = s.repo.ReplaceServerTools(ctx, serverID, items, input.OverwriteCustomizedMetadata); err != nil { return fail(err) } result, err := s.repo.ListTools(ctx, serverID, false) @@ -198,8 +199,9 @@ func (s *Service) SyncServerTools(ctx context.Context, input SyncServerToolsInpu return fail(err) } s.writeToolSyncEvent(ctx, input.RequestID, "info", "mcp.tools_synced", serverID, "MCP 工具已同步", map[string]interface{}{ - "server_id": serverID, - "tool_count": len(result), + "server_id": serverID, + "tool_count": len(result), + "overwrite_customized_metadata": input.OverwriteCustomizedMetadata, }) return result, nil } diff --git a/backend/internal/domain/mcp/types.go b/backend/internal/domain/mcp/types.go index 0755f049..dc05edcd 100644 --- a/backend/internal/domain/mcp/types.go +++ b/backend/internal/domain/mcp/types.go @@ -4,19 +4,20 @@ import "time" // Server 表示管理员维护的 MCP 服务。 type Server struct { - ID uint - Name string - BaseURL string - AuthTokenEnc string - HeadersJSON string - Status string - SortOrder int - ToolCount int - ActiveToolCount int - LastSyncedAt *time.Time - LastError string - CreatedAt time.Time - UpdatedAt time.Time + ID uint + Name string + BaseURL string + AuthTokenEnc string + HeadersJSON string + Status string + SortOrder int + ToolCount int + ActiveToolCount int + RequiresToolMetadataSyncConfirmation bool + LastSyncedAt *time.Time + LastError string + CreatedAt time.Time + UpdatedAt time.Time } type ServerWithTools struct { diff --git a/backend/internal/infra/persistence/models/mcp.go b/backend/internal/infra/persistence/models/mcp.go index 9fa8d787..04555aed 100644 --- a/backend/internal/infra/persistence/models/mcp.go +++ b/backend/internal/infra/persistence/models/mcp.go @@ -23,13 +23,14 @@ func (MCPServer) TableName() string { // MCPTool 存储 MCP 服务发现的工具。 type MCPTool struct { ControlPlaneModel - ServerID uint `gorm:"not null;default:0;uniqueIndex:idx_mcp_tools_server_name,priority:1;index:idx_mcp_tools_server_id;comment:MCP服务ID"` - Name string `gorm:"size:160;not null;default:'';uniqueIndex:idx_mcp_tools_server_name,priority:2;comment:工具名称"` - DisplayName string `gorm:"size:160;not null;default:'';comment:展示名称"` - Description string `gorm:"type:text;not null;default:'';comment:工具说明"` - InputSchemaJSON string `gorm:"type:text;not null;default:'{}';comment:输入JSON Schema"` - Status string `gorm:"size:32;not null;default:'inactive';index:idx_mcp_tools_status;comment:工具状态(active/inactive)"` - SortOrder int `gorm:"not null;default:0;index:idx_mcp_tools_sort_order;comment:展示顺序"` + ServerID uint `gorm:"not null;default:0;uniqueIndex:idx_mcp_tools_server_name,priority:1;index:idx_mcp_tools_server_id;comment:MCP服务ID"` + Name string `gorm:"size:160;not null;default:'';uniqueIndex:idx_mcp_tools_server_name,priority:2;comment:工具名称"` + DisplayName string `gorm:"size:160;not null;default:'';comment:展示名称"` + Description string `gorm:"type:text;not null;default:'';comment:工具说明"` + MetadataCustomized *bool `gorm:"comment:名称或说明是否由管理员修改(NULL表示升级前状态待确认)"` + InputSchemaJSON string `gorm:"type:text;not null;default:'{}';comment:输入JSON Schema"` + Status string `gorm:"size:32;not null;default:'inactive';index:idx_mcp_tools_status;comment:工具状态(active/inactive)"` + SortOrder int `gorm:"not null;default:0;index:idx_mcp_tools_sort_order;comment:展示顺序"` } func (MCPTool) TableName() string { diff --git a/backend/internal/infra/persistence/postgres/mcp/repository.go b/backend/internal/infra/persistence/postgres/mcp/repository.go index 0fced352..f9f943d4 100644 --- a/backend/internal/infra/persistence/postgres/mcp/repository.go +++ b/backend/internal/infra/persistence/postgres/mcp/repository.go @@ -85,6 +85,7 @@ func listServers(ctx context.Context, db *gorm.DB) ([]domainmcp.Server, error) { return nil, err } activeCounts := map[uint]int{} + metadataConfirmationServers := map[uint]bool{} if len(rows) > 0 { serverIDs := make([]uint, 0, len(rows)) for _, row := range rows { @@ -105,11 +106,23 @@ func listServers(ctx context.Context, db *gorm.DB) ([]domainmcp.Server, error) { for _, item := range counts { activeCounts[item.ServerID] = item.Count } + var confirmationServerIDs []uint + if err := db.WithContext(ctx). + Model(&model.MCPTool{}). + Distinct("server_id"). + Where("server_id IN ? AND (metadata_customized = ? OR metadata_customized IS NULL)", serverIDs, true). + Pluck("server_id", &confirmationServerIDs).Error; err != nil { + return nil, err + } + for _, serverID := range confirmationServerIDs { + metadataConfirmationServers[serverID] = true + } } items := make([]domainmcp.Server, 0, len(rows)) for _, row := range rows { item := toDomainServer(row) item.ActiveToolCount = activeCounts[row.ID] + item.RequiresToolMetadataSyncConfirmation = metadataConfirmationServers[row.ID] items = append(items, item) } return items, nil @@ -121,6 +134,22 @@ func (r *Repo) GetServer(ctx context.Context, serverID uint) (*domainmcp.Server, return nil, err } item := toDomainServer(row) + var activeToolCount int64 + if err := r.db.WithContext(ctx). + Model(&model.MCPTool{}). + Where("server_id = ? AND status = ?", serverID, "active"). + Count(&activeToolCount).Error; err != nil { + return nil, err + } + var metadataConfirmationCount int64 + if err := r.db.WithContext(ctx). + Model(&model.MCPTool{}). + Where("server_id = ? AND (metadata_customized = ? OR metadata_customized IS NULL)", serverID, true). + Count(&metadataConfirmationCount).Error; err != nil { + return nil, err + } + item.ActiveToolCount = int(activeToolCount) + item.RequiresToolMetadataSyncConfirmation = metadataConfirmationCount > 0 return &item, nil } @@ -140,7 +169,7 @@ func (r *Repo) DeleteServer(ctx context.Context, serverID uint) error { }) } -func (r *Repo) ReplaceServerTools(ctx context.Context, serverID uint, tools []domainmcp.Tool) error { +func (r *Repo) ReplaceServerTools(ctx context.Context, serverID uint, tools []domainmcp.Tool, overwriteCustomizedMetadata bool) error { now := time.Now() return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var maxSortOrder int @@ -153,24 +182,47 @@ func (r *Repo) ReplaceServerTools(ctx context.Context, serverID uint, tools []do rows := make([]model.MCPTool, 0, len(tools)) names := make([]string, 0, len(tools)) for index, tool := range tools { + metadataCustomized := false names = append(names, tool.Name) rows = append(rows, model.MCPTool{ - ServerID: serverID, - Name: tool.Name, - DisplayName: tool.DisplayName, - Description: tool.Description, - InputSchemaJSON: tool.InputSchemaJSON, - Status: tool.Status, - SortOrder: maxSortOrder + (index+1)*100, + ServerID: serverID, + Name: tool.Name, + DisplayName: tool.DisplayName, + Description: tool.Description, + MetadataCustomized: &metadataCustomized, + InputSchemaJSON: tool.InputSchemaJSON, + Status: tool.Status, + SortOrder: maxSortOrder + (index+1)*100, }) } if len(rows) > 0 { + targetColumn := func(name string) string { + if tx.Dialector.Name() == "postgres" { + return `"mcp_tools"."` + name + `"` + } + return `"` + name + `"` + } + metadataCustomizedColumn := targetColumn("metadata_customized") + displayNameColumn := targetColumn("display_name") + descriptionColumn := targetColumn("description") + legacyMetadataDiffers := "(" + displayNameColumn + ` <> excluded."display_name" OR ` + descriptionColumn + ` <> excluded."description")` + metadataAssignments := map[string]interface{}{ + "display_name": gorm.Expr("CASE WHEN COALESCE(" + metadataCustomizedColumn + ", TRUE) THEN " + displayNameColumn + ` ELSE excluded."display_name" END`), + "description": gorm.Expr("CASE WHEN COALESCE(" + metadataCustomizedColumn + ", TRUE) THEN " + descriptionColumn + ` ELSE excluded."description" END`), + "metadata_customized": gorm.Expr("CASE WHEN " + metadataCustomizedColumn + " IS NULL THEN " + legacyMetadataDiffers + " ELSE " + metadataCustomizedColumn + " END"), + } + if overwriteCustomizedMetadata { + metadataAssignments = map[string]interface{}{ + "display_name": gorm.Expr(`excluded."display_name"`), + "description": gorm.Expr(`excluded."description"`), + "metadata_customized": false, + } + } + metadataAssignments["input_schema_json"] = gorm.Expr(`excluded."input_schema_json"`) + metadataAssignments["updated_at"] = gorm.Expr(`excluded."updated_at"`) if err := tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "server_id"}, {Name: "name"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "input_schema_json", - "updated_at", - }), + Columns: []clause.Column{{Name: "server_id"}, {Name: "name"}}, + DoUpdates: clause.Assignments(metadataAssignments), }).Create(&rows).Error; err != nil { return err } @@ -249,27 +301,43 @@ func (r *Repo) ListToolsByIDs(ctx context.Context, toolIDs []uint) ([]domainmcp. } func (r *Repo) UpdateTool(ctx context.Context, toolID uint, input repository.UpdateMCPToolInput) (*domainmcp.Tool, error) { - updates := map[string]interface{}{} - if input.DisplayName != nil { - updates["display_name"] = *input.DisplayName - } - if input.Description != nil { - updates["description"] = *input.Description - } - if input.Status != nil { - updates["status"] = *input.Status - } - if len(updates) > 0 { - if err := r.db.WithContext(ctx).Model(&model.MCPTool{}).Where("id = ?", toolID).Updates(updates).Error; err != nil { - return nil, err + var result domainmcp.Tool + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var row model.MCPTool + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, "id = ?", toolID).Error; err != nil { + return err } - } - var row model.MCPTool - if err := r.db.WithContext(ctx).First(&row, "id = ?", toolID).Error; err != nil { + updates := map[string]interface{}{} + metadataChanged := false + if input.DisplayName != nil && *input.DisplayName != row.DisplayName { + updates["display_name"] = *input.DisplayName + metadataChanged = true + } + if input.Description != nil && *input.Description != row.Description { + updates["description"] = *input.Description + metadataChanged = true + } + if metadataChanged { + updates["metadata_customized"] = true + } + if input.Status != nil && *input.Status != row.Status { + updates["status"] = *input.Status + } + if len(updates) > 0 { + if err := tx.Model(&model.MCPTool{}).Where("id = ?", toolID).Updates(updates).Error; err != nil { + return err + } + if err := tx.First(&row, "id = ?", toolID).Error; err != nil { + return err + } + } + result = toDomainTool(row) + return nil + }) + if err != nil { return nil, err } - item := toDomainTool(row) - return &item, nil + return &result, nil } func (r *Repo) UpdateServerToolsStatus(ctx context.Context, serverID uint, toolIDs []uint, status string) ([]domainmcp.Tool, error) { diff --git a/backend/internal/infra/persistence/postgres/mcp/repository_sqlite_test.go b/backend/internal/infra/persistence/postgres/mcp/repository_sqlite_test.go index 362a974f..9a144aef 100644 --- a/backend/internal/infra/persistence/postgres/mcp/repository_sqlite_test.go +++ b/backend/internal/infra/persistence/postgres/mcp/repository_sqlite_test.go @@ -22,7 +22,7 @@ func TestReorderServersWithToolsSQLitePersistsToolOrder(t *testing.T) { if err := repo.ReplaceServerTools(ctx, server.ID, []domainmcp.Tool{ {Name: "tool_a", DisplayName: "Tool A", InputSchemaJSON: "{}", Status: "active"}, {Name: "tool_b", DisplayName: "Tool B", InputSchemaJSON: "{}", Status: "active"}, - }); err != nil { + }, false); err != nil { t.Fatalf("replace tools: %v", err) } initial, err := repo.ListTools(ctx, server.ID, false) @@ -47,7 +47,7 @@ func TestReorderServersWithToolsSQLitePersistsToolOrder(t *testing.T) { {Name: "tool_a", DisplayName: "Tool A", InputSchemaJSON: `{"type":"object"}`, Status: "active"}, {Name: "tool_b", DisplayName: "Tool B", InputSchemaJSON: "{}", Status: "active"}, {Name: "tool_c", DisplayName: "Tool C", InputSchemaJSON: "{}", Status: "active"}, - }); err != nil { + }, false); err != nil { t.Fatalf("replace tools after reorder: %v", err) } afterSync, err := repo.ListTools(ctx, server.ID, false) @@ -60,6 +60,273 @@ func TestReorderServersWithToolsSQLitePersistsToolOrder(t *testing.T) { } } +func TestReplaceServerToolsRefreshesRemoteMetadataAndPreservesCustomizedMetadata(t *testing.T) { + db := openMCPSQLiteTestDB(t) + ctx := context.Background() + repo := NewRepo(db) + server := createMCPServer(t, db, "server-metadata") + + if err := repo.ReplaceServerTools(ctx, server.ID, []domainmcp.Tool{ + { + Name: "tool_a", + DisplayName: "Old title", + Description: "Old description", + InputSchemaJSON: `{"type":"object","required":["old"]}`, + Status: "active", + }, + }, false); err != nil { + t.Fatalf("replace initial tools: %v", err) + } + initial, err := repo.ListTools(ctx, server.ID, false) + if err != nil { + t.Fatalf("list initial tools: %v", err) + } + if len(initial) != 1 { + t.Fatalf("initial tools = %#v, want one tool", initial) + } + initialTool := initial[0] + inactive := "inactive" + if _, err = repo.UpdateTool(ctx, initialTool.ID, repository.UpdateMCPToolInput{Status: &inactive}); err != nil { + t.Fatalf("disable tool: %v", err) + } + + if err = repo.ReplaceServerTools(ctx, server.ID, []domainmcp.Tool{ + { + Name: "tool_a", + DisplayName: "New title", + Description: "New description", + InputSchemaJSON: `{"type":"object","required":["current"]}`, + Status: "active", + }, + }, false); err != nil { + t.Fatalf("replace updated tools: %v", err) + } + + updated, err := repo.ListTools(ctx, server.ID, false) + if err != nil { + t.Fatalf("list updated tools: %v", err) + } + if len(updated) != 1 { + t.Fatalf("updated tools = %#v, want one tool", updated) + } + updatedTool := updated[0] + if updatedTool.ID != initialTool.ID { + t.Fatalf("tool id = %d, want preserved id %d", updatedTool.ID, initialTool.ID) + } + if updatedTool.DisplayName != "New title" || updatedTool.Description != "New description" { + t.Fatalf("tool metadata = %q/%q, want refreshed values", updatedTool.DisplayName, updatedTool.Description) + } + if updatedTool.InputSchemaJSON != `{"type":"object","required":["current"]}` { + t.Fatalf("tool schema = %s, want refreshed schema", updatedTool.InputSchemaJSON) + } + if updatedTool.Status != "inactive" || updatedTool.SortOrder != initialTool.SortOrder { + t.Fatalf("local controls = %s/%d, want inactive/%d", updatedTool.Status, updatedTool.SortOrder, initialTool.SortOrder) + } + storedTool := loadStoredMCPTool(t, db, updatedTool.ID) + if storedTool.MetadataCustomized == nil || *storedTool.MetadataCustomized { + t.Fatal("remote metadata refresh unexpectedly marked tool as customized") + } + unchangedTitle := updatedTool.DisplayName + unchangedDescription := updatedTool.Description + if _, err = repo.UpdateTool(ctx, updatedTool.ID, repository.UpdateMCPToolInput{ + DisplayName: &unchangedTitle, + Description: &unchangedDescription, + }); err != nil { + t.Fatalf("save unchanged metadata: %v", err) + } + storedTool = loadStoredMCPTool(t, db, updatedTool.ID) + if storedTool.MetadataCustomized == nil || *storedTool.MetadataCustomized { + t.Fatal("saving unchanged metadata marked tool as customized") + } + + customTitle := "Custom title" + customDescription := "Custom description" + if _, err = repo.UpdateTool(ctx, initialTool.ID, repository.UpdateMCPToolInput{ + DisplayName: &customTitle, + Description: &customDescription, + }); err != nil { + t.Fatalf("customize tool metadata: %v", err) + } + if err = repo.ReplaceServerTools(ctx, server.ID, []domainmcp.Tool{ + { + Name: "tool_a", + DisplayName: "Latest remote title", + Description: "Latest remote description", + InputSchemaJSON: `{"type":"object","required":["latest"]}`, + Status: "active", + }, + }, false); err != nil { + t.Fatalf("replace tools after customization: %v", err) + } + + afterCustomization, err := repo.ListTools(ctx, server.ID, false) + if err != nil { + t.Fatalf("list tools after customization: %v", err) + } + if len(afterCustomization) != 1 { + t.Fatalf("tools after customization = %#v, want one tool", afterCustomization) + } + customizedTool := afterCustomization[0] + if customizedTool.DisplayName != customTitle || customizedTool.Description != customDescription { + t.Fatalf("effective metadata = %q/%q, want custom values", customizedTool.DisplayName, customizedTool.Description) + } + if customizedTool.InputSchemaJSON != `{"type":"object","required":["latest"]}` { + t.Fatalf("tool schema = %s, want latest remote schema", customizedTool.InputSchemaJSON) + } + if customizedTool.Status != "inactive" || customizedTool.SortOrder != initialTool.SortOrder { + t.Fatalf("local controls after customization = %s/%d, want inactive/%d", customizedTool.Status, customizedTool.SortOrder, initialTool.SortOrder) + } + storedTool = loadStoredMCPTool(t, db, customizedTool.ID) + if storedTool.MetadataCustomized == nil || !*storedTool.MetadataCustomized { + t.Fatal("administrator metadata update was not marked as customized") + } + servers, err := repo.ListServers(ctx) + if err != nil { + t.Fatalf("list servers after customization: %v", err) + } + if len(servers) != 1 || !servers[0].RequiresToolMetadataSyncConfirmation { + t.Fatalf("server customization flag = %#v, want true", servers) + } + serverAfterCustomization, err := repo.GetServer(ctx, server.ID) + if err != nil { + t.Fatalf("get server after customization: %v", err) + } + if !serverAfterCustomization.RequiresToolMetadataSyncConfirmation { + t.Fatal("single server response did not require metadata sync confirmation") + } + + if err = repo.ReplaceServerTools(ctx, server.ID, []domainmcp.Tool{ + { + Name: "tool_a", + DisplayName: "Latest remote title", + Description: "Latest remote description", + InputSchemaJSON: `{"type":"object","required":["overwritten"]}`, + Status: "active", + }, + }, true); err != nil { + t.Fatalf("replace tools with overwrite: %v", err) + } + afterOverwrite, err := repo.ListTools(ctx, server.ID, false) + if err != nil { + t.Fatalf("list tools after overwrite: %v", err) + } + if len(afterOverwrite) != 1 { + t.Fatalf("tools after overwrite = %#v, want one tool", afterOverwrite) + } + overwrittenTool := afterOverwrite[0] + if overwrittenTool.DisplayName != "Latest remote title" || overwrittenTool.Description != "Latest remote description" { + t.Fatalf("overwritten metadata = %q/%q, want latest remote values", overwrittenTool.DisplayName, overwrittenTool.Description) + } + if overwrittenTool.InputSchemaJSON != `{"type":"object","required":["overwritten"]}` { + t.Fatalf("tool schema after overwrite = %s", overwrittenTool.InputSchemaJSON) + } + storedTool = loadStoredMCPTool(t, db, overwrittenTool.ID) + if storedTool.MetadataCustomized == nil || *storedTool.MetadataCustomized { + t.Fatal("overwritten remote metadata remained marked as customized") + } + if overwrittenTool.Status != "inactive" || overwrittenTool.SortOrder != initialTool.SortOrder { + t.Fatalf("local controls after overwrite = %s/%d, want inactive/%d", overwrittenTool.Status, overwrittenTool.SortOrder, initialTool.SortOrder) + } + servers, err = repo.ListServers(ctx) + if err != nil { + t.Fatalf("list servers after overwrite: %v", err) + } + if len(servers) != 1 || servers[0].RequiresToolMetadataSyncConfirmation { + t.Fatalf("server customization flag = %#v, want false", servers) + } + serverAfterOverwrite, err := repo.GetServer(ctx, server.ID) + if err != nil { + t.Fatalf("get server after overwrite: %v", err) + } + if serverAfterOverwrite.RequiresToolMetadataSyncConfirmation { + t.Fatal("single server response still required metadata sync confirmation after overwrite") + } +} + +func TestReplaceServerToolsPreservesLegacyMetadataAfterConfirmation(t *testing.T) { + db := openMCPSQLiteTestDB(t) + ctx := context.Background() + repo := NewRepo(db) + server := createMCPServer(t, db, "server-legacy-metadata") + if err := repo.ReplaceServerTools(ctx, server.ID, []domainmcp.Tool{ + { + Name: "tool_a", + DisplayName: "Remote title", + Description: "Remote description", + InputSchemaJSON: "{}", + Status: "active", + }, + { + Name: "tool_b", + DisplayName: "Stable remote title", + Description: "Stable remote description", + InputSchemaJSON: "{}", + Status: "active", + }, + }, false); err != nil { + t.Fatalf("replace initial tools: %v", err) + } + tools, err := repo.ListTools(ctx, server.ID, false) + if err != nil || len(tools) != 2 { + t.Fatalf("list initial tools = %#v, error = %v", tools, err) + } + if err = db.Model(&model.MCPTool{}). + Where("server_id = ?", server.ID). + UpdateColumn("metadata_customized", nil).Error; err != nil { + t.Fatalf("simulate legacy metadata state: %v", err) + } + if err = db.Model(&model.MCPTool{}). + Where("server_id = ? AND name = ?", server.ID, "tool_a"). + UpdateColumns(map[string]interface{}{ + "display_name": "Existing title", + "description": "Existing description", + }).Error; err != nil { + t.Fatalf("simulate legacy metadata: %v", err) + } + servers, err := repo.ListServers(ctx) + if err != nil || len(servers) != 1 || !servers[0].RequiresToolMetadataSyncConfirmation { + t.Fatalf("legacy confirmation state = %#v, error = %v", servers, err) + } + + if err = repo.ReplaceServerTools(ctx, server.ID, []domainmcp.Tool{ + { + Name: "tool_a", + DisplayName: "Latest remote title", + Description: "Latest remote description", + InputSchemaJSON: `{"type":"object"}`, + Status: "active", + }, + { + Name: "tool_b", + DisplayName: "Stable remote title", + Description: "Stable remote description", + InputSchemaJSON: `{"type":"object"}`, + Status: "active", + }, + }, false); err != nil { + t.Fatalf("preserve legacy metadata: %v", err) + } + preserved, err := repo.ListTools(ctx, server.ID, false) + if err != nil || len(preserved) != 2 { + t.Fatalf("list preserved tools = %#v, error = %v", preserved, err) + } + toolsByName := map[string]domainmcp.Tool{} + for _, tool := range preserved { + toolsByName[tool.Name] = tool + } + if toolsByName["tool_a"].DisplayName != "Existing title" || toolsByName["tool_a"].Description != "Existing description" { + t.Fatalf("preserved metadata = %q/%q", toolsByName["tool_a"].DisplayName, toolsByName["tool_a"].Description) + } + customized := loadStoredMCPTool(t, db, toolsByName["tool_a"].ID) + if customized.MetadataCustomized == nil || !*customized.MetadataCustomized { + t.Fatalf("changed legacy metadata state = %v, want true", customized.MetadataCustomized) + } + remoteManaged := loadStoredMCPTool(t, db, toolsByName["tool_b"].ID) + if remoteManaged.MetadataCustomized == nil || *remoteManaged.MetadataCustomized { + t.Fatalf("unchanged legacy metadata state = %v, want false", remoteManaged.MetadataCustomized) + } +} + func TestRemovingMCPToolsCleansConversationProjectAssociations(t *testing.T) { db := openMCPSQLiteTestDB(t) ctx := context.Background() @@ -68,7 +335,7 @@ func TestRemovingMCPToolsCleansConversationProjectAssociations(t *testing.T) { if err := repo.ReplaceServerTools(ctx, server.ID, []domainmcp.Tool{ {Name: "tool_a", DisplayName: "Tool A", InputSchemaJSON: "{}", Status: "active"}, {Name: "tool_b", DisplayName: "Tool B", InputSchemaJSON: "{}", Status: "active"}, - }); err != nil { + }, false); err != nil { t.Fatalf("replace tools: %v", err) } tools, err := repo.ListTools(ctx, server.ID, false) @@ -83,7 +350,7 @@ func TestRemovingMCPToolsCleansConversationProjectAssociations(t *testing.T) { if err = repo.ReplaceServerTools(ctx, server.ID, []domainmcp.Tool{ {Name: "tool_b", DisplayName: "Tool B", InputSchemaJSON: "{}", Status: "active"}, - }); err != nil { + }, false); err != nil { t.Fatalf("replace tools with removal: %v", err) } var associations []model.ConversationProjectMCPTool @@ -115,12 +382,12 @@ func TestReorderServersWithToolsSQLiteRejectsForeignTool(t *testing.T) { serverB := createMCPServer(t, db, "server-b") if err := repo.ReplaceServerTools(ctx, serverA.ID, []domainmcp.Tool{ {Name: "tool_a", DisplayName: "Tool A", InputSchemaJSON: "{}", Status: "active"}, - }); err != nil { + }, false); err != nil { t.Fatalf("replace server a tools: %v", err) } if err := repo.ReplaceServerTools(ctx, serverB.ID, []domainmcp.Tool{ {Name: "tool_b", DisplayName: "Tool B", InputSchemaJSON: "{}", Status: "active"}, - }); err != nil { + }, false); err != nil { t.Fatalf("replace server b tools: %v", err) } serverBTools, err := repo.ListTools(ctx, serverB.ID, false) @@ -144,7 +411,7 @@ func TestReorderServersWithToolsSQLiteRejectsPartialToolOrder(t *testing.T) { if err := repo.ReplaceServerTools(ctx, server.ID, []domainmcp.Tool{ {Name: "tool_a", DisplayName: "Tool A", InputSchemaJSON: "{}", Status: "active"}, {Name: "tool_b", DisplayName: "Tool B", InputSchemaJSON: "{}", Status: "active"}, - }); err != nil { + }, false); err != nil { t.Fatalf("replace tools: %v", err) } tools, err := repo.ListTools(ctx, server.ID, false) @@ -168,12 +435,12 @@ func TestReorderServersWithToolsSQLitePersistsServerOrder(t *testing.T) { serverB := createMCPServer(t, db, "server-b") if err := repo.ReplaceServerTools(ctx, serverA.ID, []domainmcp.Tool{ {Name: "tool_a", DisplayName: "Tool A", InputSchemaJSON: "{}", Status: "active"}, - }); err != nil { + }, false); err != nil { t.Fatalf("replace server a tools: %v", err) } if err := repo.ReplaceServerTools(ctx, serverB.ID, []domainmcp.Tool{ {Name: "tool_b", DisplayName: "Tool B", InputSchemaJSON: "{}", Status: "active"}, - }); err != nil { + }, false); err != nil { t.Fatalf("replace server b tools: %v", err) } serverATools, err := repo.ListTools(ctx, serverA.ID, false) @@ -221,6 +488,15 @@ func createMCPServer(t *testing.T, db *gorm.DB, name string) model.MCPServer { return server } +func loadStoredMCPTool(t *testing.T, db *gorm.DB, toolID uint) model.MCPTool { + t.Helper() + var tool model.MCPTool + if err := db.First(&tool, "id = ?", toolID).Error; err != nil { + t.Fatalf("load stored MCP tool: %v", err) + } + return tool +} + func assertToolNames(t *testing.T, tools []domainmcp.Tool, want []string) { t.Helper() got := make([]string, 0, len(tools)) diff --git a/backend/internal/infra/persistence/schema/schema_test.go b/backend/internal/infra/persistence/schema/schema_test.go index f1e8cf28..1dd106a2 100644 --- a/backend/internal/infra/persistence/schema/schema_test.go +++ b/backend/internal/infra/persistence/schema/schema_test.go @@ -3,6 +3,7 @@ package schema import ( "strings" "testing" + "time" domainchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/channel" model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" @@ -10,6 +11,61 @@ import ( "gorm.io/gorm" ) +type legacyMCPTool struct { + ID uint `gorm:"primaryKey"` + ServerID uint + Name string + DisplayName string + Description string + InputSchemaJSON string + Status string + SortOrder int + UpdatedAt time.Time +} + +func (legacyMCPTool) TableName() string { + return "mcp_tools" +} + +func TestMigrateLeavesLegacyMCPToolMetadataPendingConfirmation(t *testing.T) { + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err = db.AutoMigrate(&legacyMCPTool{}); err != nil { + t.Fatalf("migrate legacy MCP tool: %v", err) + } + updatedAt := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + legacy := legacyMCPTool{ + ServerID: 1, + Name: "tool_a", + DisplayName: "Existing title", + Description: "Existing description", + InputSchemaJSON: "{}", + Status: "active", + UpdatedAt: updatedAt, + } + if err = db.Create(&legacy).Error; err != nil { + t.Fatalf("create legacy MCP tool: %v", err) + } + + if err = Migrate(db); err != nil { + t.Fatalf("Migrate() error = %v", err) + } + + var migrated model.MCPTool + if err = db.First(&migrated, legacy.ID).Error; err != nil { + t.Fatalf("load migrated MCP tool: %v", err) + } + if migrated.MetadataCustomized != nil { + t.Fatalf("legacy metadata state = %v, want pending confirmation", *migrated.MetadataCustomized) + } + if !migrated.UpdatedAt.Equal(updatedAt) { + t.Fatalf("legacy updated_at = %s, want %s", migrated.UpdatedAt, updatedAt) + } +} + func TestSeedBillingCatalogBindsDefaultPermissionGroup(t *testing.T) { db := openSchemaTestDB(t) if err := SeedPermissionGroups(db); err != nil { diff --git a/backend/internal/repository/mcp.go b/backend/internal/repository/mcp.go index dee569c5..922840d1 100644 --- a/backend/internal/repository/mcp.go +++ b/backend/internal/repository/mcp.go @@ -44,7 +44,7 @@ type MCPRepository interface { ListServers(ctx context.Context) ([]domainmcp.Server, error) GetServer(ctx context.Context, serverID uint) (*domainmcp.Server, error) DeleteServer(ctx context.Context, serverID uint) error - ReplaceServerTools(ctx context.Context, serverID uint, tools []domainmcp.Tool) error + ReplaceServerTools(ctx context.Context, serverID uint, tools []domainmcp.Tool, overwriteCustomizedMetadata bool) error ListTools(ctx context.Context, serverID uint, onlyActive bool) ([]domainmcp.Tool, error) ListToolsByIDs(ctx context.Context, toolIDs []uint) ([]domainmcp.Tool, error) UpdateTool(ctx context.Context, toolID uint, input UpdateMCPToolInput) (*domainmcp.Tool, error) diff --git a/backend/internal/transport/http/mcp/dto.go b/backend/internal/transport/http/mcp/dto.go index 98fce18f..4c8ef7a4 100644 --- a/backend/internal/transport/http/mcp/dto.go +++ b/backend/internal/transport/http/mcp/dto.go @@ -3,18 +3,19 @@ package mcp import "time" type ServerResponse struct { - ID uint `json:"id"` - Name string `json:"name"` - BaseURL string `json:"baseURL"` - HeadersJSON string `json:"headersJSON"` - Status string `json:"status"` - SortOrder int `json:"sortOrder"` - ToolCount int `json:"toolCount"` - ActiveToolCount int `json:"activeToolCount"` - LastSyncedAt *time.Time `json:"lastSyncedAt" extensions:"x-nullable,!x-omitempty"` - LastError string `json:"lastError"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `json:"id"` + Name string `json:"name"` + BaseURL string `json:"baseURL"` + HeadersJSON string `json:"headersJSON"` + Status string `json:"status"` + SortOrder int `json:"sortOrder"` + ToolCount int `json:"toolCount"` + ActiveToolCount int `json:"activeToolCount"` + RequiresToolMetadataSyncConfirmation bool `json:"requiresToolMetadataSyncConfirmation"` + LastSyncedAt *time.Time `json:"lastSyncedAt" extensions:"x-nullable,!x-omitempty"` + LastError string `json:"lastError"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type ToolResponse struct { diff --git a/backend/internal/transport/http/mcp/handler.go b/backend/internal/transport/http/mcp/handler.go index fda33635..a5c0ce0a 100644 --- a/backend/internal/transport/http/mcp/handler.go +++ b/backend/internal/transport/http/mcp/handler.go @@ -164,6 +164,7 @@ func (h *Handler) DeleteServer(c *gin.Context) { // @Produce json // @Security BearerAuth // @Param id path int true "MCP 服务 ID" +// @Param overwrite_customized_metadata query bool false "是否用远端元数据覆盖管理员自定义的工具名称和说明" // @Success 200 {object} ToolListResponseDoc // @Failure 400 {object} ErrorDoc // @Failure 500 {object} ErrorDoc @@ -173,9 +174,19 @@ func (h *Handler) SyncServerTools(c *gin.Context) { if !ok { return } + overwriteCustomizedMetadata := false + if raw, exists := c.GetQuery("overwrite_customized_metadata"); exists { + parsed, err := strconv.ParseBool(raw) + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid overwrite_customized_metadata") + return + } + overwriteCustomizedMetadata = parsed + } items, err := h.service.SyncServerTools(c.Request.Context(), appmcp.SyncServerToolsInput{ - ServerID: serverID, - RequestID: middleware.MustRequestID(c), + ServerID: serverID, + RequestID: middleware.MustRequestID(c), + OverwriteCustomizedMetadata: overwriteCustomizedMetadata, }) if err != nil { writeServiceError(c, err) @@ -358,18 +369,19 @@ func writeServiceError(c *gin.Context, err error) { func toServerResponse(item domainmcp.Server) ServerResponse { return ServerResponse{ - ID: item.ID, - Name: item.Name, - BaseURL: item.BaseURL, - HeadersJSON: security.RedactHeadersJSON(item.HeadersJSON), - Status: item.Status, - SortOrder: item.SortOrder, - ToolCount: item.ToolCount, - ActiveToolCount: item.ActiveToolCount, - LastSyncedAt: item.LastSyncedAt, - LastError: item.LastError, - CreatedAt: item.CreatedAt, - UpdatedAt: item.UpdatedAt, + ID: item.ID, + Name: item.Name, + BaseURL: item.BaseURL, + HeadersJSON: security.RedactHeadersJSON(item.HeadersJSON), + Status: item.Status, + SortOrder: item.SortOrder, + ToolCount: item.ToolCount, + ActiveToolCount: item.ActiveToolCount, + RequiresToolMetadataSyncConfirmation: item.RequiresToolMetadataSyncConfirmation, + LastSyncedAt: item.LastSyncedAt, + LastError: item.LastError, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, } } diff --git a/frontend/features/admin/api/mcp.ts b/frontend/features/admin/api/mcp.ts index 9b61b760..49c1a691 100644 --- a/frontend/features/admin/api/mcp.ts +++ b/frontend/features/admin/api/mcp.ts @@ -81,9 +81,14 @@ export async function listAdminMCPServerTools(accessToken: string, serverID: num return data.results ?? []; } -export async function syncAdminMCPServerTools(accessToken: string, serverID: number): Promise { +export async function syncAdminMCPServerTools( + accessToken: string, + serverID: number, + overwriteCustomizedMetadata = false, +): Promise { + const query = overwriteCustomizedMetadata ? "?overwrite_customized_metadata=true" : ""; const data = await authedRequest( - `/api/v1/admin/mcp/servers/${pathParam(String(serverID))}/sync`, + `/api/v1/admin/mcp/servers/${pathParam(String(serverID))}/sync${query}`, { method: "POST", accessToken, diff --git a/frontend/features/admin/components/sections/tools/admin-tools.tsx b/frontend/features/admin/components/sections/tools/admin-tools.tsx index 6803c4ec..b0ac718d 100644 --- a/frontend/features/admin/components/sections/tools/admin-tools.tsx +++ b/frontend/features/admin/components/sections/tools/admin-tools.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { CheckCircle2, FileBraces, ListOrdered, Pencil, Plus, RefreshCw, Save, Trash2, Wrench, XCircle } from "lucide-react"; +import { CheckCircle2, FileBraces, ListOrdered, Pencil, Plus, RefreshCw, Save, Trash2, Wrench, X, XCircle } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; import { toast } from "sonner"; @@ -21,6 +21,7 @@ import { } from "@/components/ui/alert-dialog"; import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -89,6 +90,11 @@ type ToolFormState = { description: string; }; +type ToolSyncConfirmation = { + serverID: number; + serverName: string; +}; + const EMPTY_SERVER_FORM: ServerFormState = { name: "", baseURL: "", @@ -185,6 +191,7 @@ export function AdminToolsPage() { const [toolBulkAction, setToolBulkAction] = React.useState(null); const [toolBulkApplying, setToolBulkApplying] = React.useState(false); const [syncingServerID, setSyncingServerID] = React.useState(null); + const [toolSyncConfirmation, setToolSyncConfirmation] = React.useState(null); const [mcpOrderOpen, setMCPOrderOpen] = React.useState(false); const [schemaTool, setSchemaTool] = React.useState(null); const [toolForm, setToolForm] = React.useState(null); @@ -211,6 +218,7 @@ export function AdminToolsPage() { const stableToolForm = useDialogSnapshot(toolForm); const stableSchemaTool = useDialogSnapshot(schemaTool); const stableServerDeleteTarget = useDialogSnapshot(serverDeleteTarget); + const stableToolSyncConfirmation = useDialogSnapshot(toolSyncConfirmation); React.useEffect(() => { if (mcpEnabled) { return; @@ -218,6 +226,7 @@ export function AdminToolsPage() { setToolSheetServerID(null); setToolForm(null); setSchemaTool(null); + setToolSyncConfirmation(null); }, [mcpEnabled]); const filteredServers = React.useMemo(() => { @@ -447,7 +456,7 @@ export function AdminToolsPage() { }, []); const syncTools = React.useCallback( - async (serverID: number) => { + async (serverID: number, overwriteCustomizedMetadata = false) => { setSyncingServerID(serverID); const token = await resolveAccessToken(); if (!token) { @@ -457,7 +466,7 @@ export function AdminToolsPage() { } try { - const nextTools = await syncAdminMCPServerTools(token, serverID); + const nextTools = await syncAdminMCPServerTools(token, serverID, overwriteCustomizedMetadata); setToolSheetServerID(serverID); setTools(nextTools); toast.success(t("toast.toolsSynced")); @@ -471,6 +480,30 @@ export function AdminToolsPage() { [loadServers, t], ); + const requestToolSync = React.useCallback( + (serverID: number) => { + const server = servers.find((item) => item.id === serverID); + if (server?.requiresToolMetadataSyncConfirmation) { + setToolSyncConfirmation({ serverID, serverName: server.name }); + return; + } + void syncTools(serverID); + }, + [servers, syncTools], + ); + + const confirmToolSync = React.useCallback( + (overwriteCustomizedMetadata: boolean) => { + if (!toolSyncConfirmation) { + return; + } + const serverID = toolSyncConfirmation.serverID; + setToolSyncConfirmation(null); + void syncTools(serverID, overwriteCustomizedMetadata); + }, + [syncTools, toolSyncConfirmation], + ); + const saveServer = React.useCallback(async () => { setServerSaving(true); try { @@ -653,6 +686,7 @@ export function AdminToolsPage() { description: toolForm.description, }); setTools((items) => items.map((item) => (item.id === savedTool.id ? savedTool : item))); + await loadServers(); setToolForm(null); toast.success(t("toast.toolUpdated")); } catch (error) { @@ -660,7 +694,7 @@ export function AdminToolsPage() { } finally { setToolSaving(false); } - }, [t, toolForm]); + }, [loadServers, t, toolForm]); const schemaText = React.useMemo(() => { const raw = stableSchemaTool?.inputSchemaJSON?.trim(); @@ -860,7 +894,7 @@ export function AdminToolsPage() { variant="ghost" className="text-muted-foreground shadow-none" disabled={syncingServerID === server.id} - onClick={() => void syncTools(server.id)} + onClick={() => void requestToolSync(server.id)} title={t("toolbar.syncTools")} aria-label={t("toolbar.syncTools")} > @@ -958,7 +992,7 @@ export function AdminToolsPage() { size="sm" className="h-7 shrink-0 text-xs" disabled={syncingServerID === toolSheetServer.id} - onClick={() => void syncTools(toolSheetServer.id)} + onClick={() => void requestToolSync(toolSheetServer.id)} > {t("toolbar.sync")} @@ -1294,6 +1328,64 @@ export function AdminToolsPage() { }} /> + { + if (!open) { + setToolSyncConfirmation(null); + } + }} + > + + + + + + {t("syncConfirm.title")} + + {t("syncConfirm.description", { + name: stableToolSyncConfirmation?.serverName ?? "", + })} + + +
+ + +
+
+
+ { diff --git a/frontend/i18n/messages/en-US/admin-tools.json b/frontend/i18n/messages/en-US/admin-tools.json index 2633fbc4..fb7a41b1 100644 --- a/frontend/i18n/messages/en-US/admin-tools.json +++ b/frontend/i18n/messages/en-US/admin-tools.json @@ -114,6 +114,14 @@ "confirm": "Confirm", "pending": "Processing" }, + "syncConfirm": { + "title": "Sync MCP tools", + "description": "Choose how to handle tool names and descriptions for \"{name}\".", + "preserve": "Keep current content", + "preserveDescription": "Keep names and descriptions while syncing other tool data", + "overwrite": "Use server content", + "overwriteDescription": "Sync names and descriptions returned by the MCP server" + }, "serverDialog": { "createTitle": "Add MCP server", "editTitle": "Edit MCP server", diff --git a/frontend/i18n/messages/zh-CN/admin-tools.json b/frontend/i18n/messages/zh-CN/admin-tools.json index a790f0d8..922d6cca 100644 --- a/frontend/i18n/messages/zh-CN/admin-tools.json +++ b/frontend/i18n/messages/zh-CN/admin-tools.json @@ -114,6 +114,14 @@ "confirm": "确认执行", "pending": "处理中" }, + "syncConfirm": { + "title": "同步 MCP 工具", + "description": "请选择「{name}」工具名称和说明的处理方式。", + "preserve": "保留当前内容", + "preserveDescription": "名称和说明不变,其他信息正常同步", + "overwrite": "使用服务端内容", + "overwriteDescription": "同步 MCP 服务返回的名称和说明" + }, "serverDialog": { "createTitle": "新增 MCP 服务", "editTitle": "编辑 MCP 服务", diff --git a/packages/api-contract/src/types.generated.ts b/packages/api-contract/src/types.generated.ts index 37aa3d7a..e2a8e336 100644 --- a/packages/api-contract/src/types.generated.ts +++ b/packages/api-contract/src/types.generated.ts @@ -2468,6 +2468,7 @@ export interface ServerResponse { lastError: string; lastSyncedAt: string | null; name: string; + requiresToolMetadataSyncConfirmation: boolean; sortOrder: number; status: string; toolCount: number; @@ -4790,7 +4791,10 @@ export namespace Admin { /** MCP 服务 ID */ id: number; }; - export type RequestQuery = {}; + export type RequestQuery = { + /** 是否用远端元数据覆盖管理员自定义的工具名称和说明 */ + overwrite_customized_metadata?: boolean; + }; export type RequestBody = never; export type RequestHeaders = {}; export type ResponseBody = ToolListResponseDoc;