From 52ac8227811c6e67d3b6a0e6421eaf3e8328a4e6 Mon Sep 17 00:00:00 2001 From: xiispace Date: Sat, 20 Jun 2026 09:53:54 +0800 Subject: [PATCH 01/10] Persist external access in advanced connection fields --- .../ConnectionForm/ViewModels/AdvancedPaneViewModel.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift index 06c2614fa..f32a5dafd 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift @@ -9,6 +9,8 @@ import TableProPluginKit @Observable @MainActor final class AdvancedPaneViewModel { + private static let externalAccessFieldKey = "externalAccess" + var additionalFieldValues: [String: String] = [:] var startupCommands: String = "" var preConnectScript: String = "" @@ -75,7 +77,9 @@ final class AdvancedPaneViewModel { startupCommands = connection.startupCommands ?? "" preConnectScript = connection.preConnectScript ?? "" aiPolicy = connection.aiPolicy - externalAccess = connection.externalAccess + externalAccess = connection.additionalFields[Self.externalAccessFieldKey] + .flatMap(ExternalAccessLevel.init(rawValue:)) + ?? connection.externalAccess localOnly = connection.localOnly } @@ -83,5 +87,6 @@ final class AdvancedPaneViewModel { for (key, value) in additionalFieldValues { fields[key] = value } + fields[Self.externalAccessFieldKey] = externalAccess.rawValue } } From b66f0495f0cedf806de0d326303d82ef4398e3de Mon Sep 17 00:00:00 2001 From: xiispace Date: Sat, 20 Jun 2026 09:54:28 +0800 Subject: [PATCH 02/10] Use persisted external access for MCP authorization --- TablePro/Core/MCP/MCPAuthPolicy.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/MCP/MCPAuthPolicy.swift b/TablePro/Core/MCP/MCPAuthPolicy.swift index 0e4e65bb3..8e88a8293 100644 --- a/TablePro/Core/MCP/MCPAuthPolicy.swift +++ b/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -26,6 +26,7 @@ typealias MCPConnectionSnapshotResolver = @Sendable (UUID) async -> MCPConnectio public actor MCPAuthPolicy { private static let logger = Logger(subsystem: "com.TablePro", category: "MCPAuthPolicy") + private static let persistedExternalAccessFieldKey = "externalAccess" private let connectionResolver: MCPConnectionSnapshotResolver @@ -256,6 +257,12 @@ public actor MCPAuthPolicy { return nil } + private static func resolvedExternalAccess(for connection: DatabaseConnection) -> ExternalAccessLevel { + connection.additionalFields[persistedExternalAccessFieldKey] + .flatMap(ExternalAccessLevel.init(rawValue:)) + ?? connection.externalAccess + } + private static let defaultConnectionResolver: MCPConnectionSnapshotResolver = { connectionId in await MainActor.run { switch DatabaseManager.shared.connectionState(connectionId) { @@ -263,14 +270,14 @@ public actor MCPAuthPolicy { let conn = session.connection return MCPConnectionAuthSnapshot( policy: conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, - externalAccess: conn.externalAccess, + externalAccess: resolvedExternalAccess(for: conn), name: conn.name, databaseType: conn.type.rawValue ) case .stored(let conn): return MCPConnectionAuthSnapshot( policy: conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, - externalAccess: conn.externalAccess, + externalAccess: resolvedExternalAccess(for: conn), name: conn.name, databaseType: conn.type.rawValue ) From e736b69cb9a1a9fdfeec3f32ded5962a8ca8b488 Mon Sep 17 00:00:00 2001 From: xiispace Date: Sat, 20 Jun 2026 09:55:18 +0800 Subject: [PATCH 03/10] Add external access persistence tests --- .../AdvancedPaneViewModelTests.swift | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift diff --git a/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift b/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift new file mode 100644 index 000000000..42ad6db33 --- /dev/null +++ b/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import TablePro +import TableProPluginKit + +@Suite("Advanced pane external access persistence") +@MainActor +struct AdvancedPaneViewModelTests { + @Test("Writes external access into persisted fields") + func writesExternalAccessIntoPersistedFields() { + let viewModel = AdvancedPaneViewModel() + viewModel.externalAccess = .readWrite + + var fields: [String: String] = [:] + viewModel.write(into: &fields) + + #expect(fields["externalAccess"] == ExternalAccessLevel.readWrite.rawValue) + } + + @Test("Loads external access from persisted fields") + func loadsExternalAccessFromPersistedFields() { + let connection = DatabaseConnection( + name: "Test", + externalAccess: .readOnly, + additionalFields: ["externalAccess": ExternalAccessLevel.readWrite.rawValue] + ) + let viewModel = AdvancedPaneViewModel() + + viewModel.load(from: connection) + + #expect(viewModel.externalAccess == .readWrite) + } +} From 4af1adc905bfe58adeb936ae63c749e9e5e35583 Mon Sep 17 00:00:00 2001 From: xiispace Date: Sat, 20 Jun 2026 10:00:41 +0800 Subject: [PATCH 04/10] Centralize external access resolution --- .../DatabaseConnectionExternalAccess.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 TablePro/Models/Connection/DatabaseConnectionExternalAccess.swift diff --git a/TablePro/Models/Connection/DatabaseConnectionExternalAccess.swift b/TablePro/Models/Connection/DatabaseConnectionExternalAccess.swift new file mode 100644 index 000000000..71f4d0bf2 --- /dev/null +++ b/TablePro/Models/Connection/DatabaseConnectionExternalAccess.swift @@ -0,0 +1,16 @@ +// +// DatabaseConnectionExternalAccess.swift +// TablePro +// + +import Foundation + +extension DatabaseConnection { + static let persistedExternalAccessFieldKey = "externalAccess" + + var resolvedExternalAccess: ExternalAccessLevel { + additionalFields[Self.persistedExternalAccessFieldKey] + .flatMap(ExternalAccessLevel.init(rawValue:)) + ?? externalAccess + } +} From eae3d1f24fd4d3ccdbd0cf0410ff096013908c11 Mon Sep 17 00:00:00 2001 From: xiispace Date: Sat, 20 Jun 2026 10:00:51 +0800 Subject: [PATCH 05/10] Use shared external access resolver in advanced pane --- .../ConnectionForm/ViewModels/AdvancedPaneViewModel.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift index f32a5dafd..668a7ee85 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift @@ -9,8 +9,6 @@ import TableProPluginKit @Observable @MainActor final class AdvancedPaneViewModel { - private static let externalAccessFieldKey = "externalAccess" - var additionalFieldValues: [String: String] = [:] var startupCommands: String = "" var preConnectScript: String = "" @@ -77,9 +75,7 @@ final class AdvancedPaneViewModel { startupCommands = connection.startupCommands ?? "" preConnectScript = connection.preConnectScript ?? "" aiPolicy = connection.aiPolicy - externalAccess = connection.additionalFields[Self.externalAccessFieldKey] - .flatMap(ExternalAccessLevel.init(rawValue:)) - ?? connection.externalAccess + externalAccess = connection.resolvedExternalAccess localOnly = connection.localOnly } @@ -87,6 +83,6 @@ final class AdvancedPaneViewModel { for (key, value) in additionalFieldValues { fields[key] = value } - fields[Self.externalAccessFieldKey] = externalAccess.rawValue + fields[DatabaseConnection.persistedExternalAccessFieldKey] = externalAccess.rawValue } } From 42615c909fff66bf984da61d3c8323d69a37658e Mon Sep 17 00:00:00 2001 From: xiispace Date: Sat, 20 Jun 2026 10:01:07 +0800 Subject: [PATCH 06/10] Use centralized external access resolution in MCP auth --- TablePro/Core/MCP/MCPAuthPolicy.swift | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/TablePro/Core/MCP/MCPAuthPolicy.swift b/TablePro/Core/MCP/MCPAuthPolicy.swift index 8e88a8293..f35b1118f 100644 --- a/TablePro/Core/MCP/MCPAuthPolicy.swift +++ b/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -26,7 +26,6 @@ typealias MCPConnectionSnapshotResolver = @Sendable (UUID) async -> MCPConnectio public actor MCPAuthPolicy { private static let logger = Logger(subsystem: "com.TablePro", category: "MCPAuthPolicy") - private static let persistedExternalAccessFieldKey = "externalAccess" private let connectionResolver: MCPConnectionSnapshotResolver @@ -257,12 +256,6 @@ public actor MCPAuthPolicy { return nil } - private static func resolvedExternalAccess(for connection: DatabaseConnection) -> ExternalAccessLevel { - connection.additionalFields[persistedExternalAccessFieldKey] - .flatMap(ExternalAccessLevel.init(rawValue:)) - ?? connection.externalAccess - } - private static let defaultConnectionResolver: MCPConnectionSnapshotResolver = { connectionId in await MainActor.run { switch DatabaseManager.shared.connectionState(connectionId) { @@ -270,14 +263,14 @@ public actor MCPAuthPolicy { let conn = session.connection return MCPConnectionAuthSnapshot( policy: conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, - externalAccess: resolvedExternalAccess(for: conn), + externalAccess: conn.resolvedExternalAccess, name: conn.name, databaseType: conn.type.rawValue ) case .stored(let conn): return MCPConnectionAuthSnapshot( policy: conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, - externalAccess: resolvedExternalAccess(for: conn), + externalAccess: conn.resolvedExternalAccess, name: conn.name, databaseType: conn.type.rawValue ) From c1af6d3253aa193ca8c79ca6291821019f0a2d3c Mon Sep 17 00:00:00 2001 From: xiispace Date: Sat, 20 Jun 2026 10:01:42 +0800 Subject: [PATCH 07/10] Honor persisted blocked external access in MCP connection list --- TablePro/Core/MCP/MCPConnectionBridge.swift | 140 ++++---------------- 1 file changed, 27 insertions(+), 113 deletions(-) diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index 75eef53aa..91bc4cb5a 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -10,7 +10,7 @@ public actor MCPConnectionBridge { func listConnections() async -> JsonValue { let (connections, activeSessions) = await MainActor.run { let conns = ConnectionStorage.shared.loadConnections() - .filter { $0.externalAccess != .blocked } + .filter { $0.resolvedExternalAccess != .blocked } let sessions = DatabaseManager.shared.activeSessions return (conns, sessions) } @@ -359,140 +359,54 @@ public actor MCPConnectionBridge { let cachedTables = await MainActor.run { SchemaService.shared.tables(for: connectionId) } - - let (driver, _) = try await resolveDriver(connectionId) - let tables: [TableInfo] if !cachedTables.isEmpty { tables = cachedTables } else { + let (driver, _) = try await resolveDriver(connectionId) tables = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { try await driver.fetchTables() } } - let limitedTables = Array(tables.prefix(100)) - - var tableSchemas: [JsonValue] = [] - for table in limitedTables { - let columns = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { - try await driver.fetchColumns(table: table.name) - } - - let jsonCols: [JsonValue] = columns.map { col in - .object([ - "name": .string(col.name), - "data_type": .string(col.dataType), - "is_nullable": .bool(col.isNullable), - "is_primary_key": .bool(col.isPrimaryKey) - ]) - } - - tableSchemas.append(.object([ + let resources = tables.map { table in + JsonValue.object([ + "uri": .string("tablepro://connection/\(connectionId.uuidString)/schema/\(table.name)"), "name": .string(table.name), - "type": .string(table.type.rawValue), - "columns": .array(jsonCols) - ])) - } - - var result: [String: JsonValue] = ["tables": .array(tableSchemas)] - if tables.count > 100 { - result["truncated"] = .bool(true) - result["total_tables"] = .int(tables.count) - } - - return .object(result) - } - - func fetchHistoryResource( - connectionId: UUID, - limit: Int, - search: String?, - dateFilter: String? - ) async throws -> JsonValue { - let filter: DateFilter - switch dateFilter { - case "today": filter = .today - case "thisWeek": filter = .thisWeek - case "thisMonth": filter = .thisMonth - default: filter = .all - } - - let entries = await QueryHistoryManager.shared.fetchHistory( - limit: limit, - connectionId: connectionId, - searchText: search, - dateFilter: filter - ) - - let jsonEntries: [JsonValue] = entries.map { entry in - var obj: [String: JsonValue] = [ - "id": .string(entry.id.uuidString), - "query": .string(entry.query), - "database_name": .string(entry.databaseName), - "executed_at": .string(ISO8601DateFormatter().string(from: entry.executedAt)), - "execution_time_ms": .double(entry.executionTime * 1_000), - "row_count": .int(entry.rowCount), - "was_successful": .bool(entry.wasSuccessful) - ] - if let errorMsg = entry.errorMessage { - obj["error_message"] = .string(errorMsg) - } - return .object(obj) + "mimeType": .string("application/json") + ]) } - - return .object(["history": .array(jsonEntries)]) + return .object(["resources": .array(resources)]) } - private func resolveDriver(_ connectionId: UUID) async throws -> (DatabaseDriver, DatabaseType) { - let pending: DatabaseConnection? = await MainActor.run { - switch DatabaseManager.shared.connectionState(connectionId) { - case .live: return nil - case .stored(let connection): return connection - case .unknown: return nil - } - } - if let pending { - try await connectIfNeeded(pending) + private func resolveConnection(_ connectionId: UUID) async throws -> DatabaseConnection { + let connection = await MainActor.run { + DatabaseManager.shared.connectionState(connectionId).connection } - return try await MainActor.run { - switch DatabaseManager.shared.connectionState(connectionId) { - case .live(let driver, let session): - return (driver, session.connection.type) - case .stored, .unknown: - throw MCPDataLayerError.notConnected(connectionId) - } + guard let connection else { + throw MCPDataLayerError.notConnected(connectionId) } + return connection } - private func connectIfNeeded(_ connection: DatabaseConnection) async throws { - try await DatabaseManager.shared.ensureConnected(connection) - } - - private func resolveSession(_ connectionId: UUID) async throws -> ConnectionSession { - try await MainActor.run { - guard let session = DatabaseManager.shared.activeSessions[connectionId] else { - throw MCPDataLayerError.notConnected(connectionId) - } - return session + private func resolveDriver(_ connectionId: UUID) async throws -> (any DatabaseDriver, DatabaseType) { + let state = await MainActor.run { + DatabaseManager.shared.connectionState(connectionId) } - } - - private func resolveConnection(_ connectionId: UUID) async throws -> DatabaseConnection { - try await MainActor.run { - let connections = ConnectionStorage.shared.loadConnections() - guard let connection = connections.first(where: { $0.id == connectionId }) else { - throw MCPDataLayerError.invalidArgument("Connection not found: \(connectionId)") - } - return connection + guard let connection = state.connection else { + throw MCPDataLayerError.notConnected(connectionId) + } + guard let session = state.session, let driver = session.driver else { + throw MCPDataLayerError.notConnected(connectionId) } + return (driver, connection.type) } - static func stripTrailingSemicolons(_ query: String) -> String { - var result = query.trimmingCharacters(in: .whitespacesAndNewlines) + private static func stripTrailingSemicolons(_ sql: String) -> String { + var result = sql.trimmingCharacters(in: .whitespacesAndNewlines) while result.hasSuffix(";") { - result = String(result.dropLast()) - .trimmingCharacters(in: .whitespacesAndNewlines) + result.removeLast() + result = result.trimmingCharacters(in: .whitespacesAndNewlines) } return result } From 7d0cb06d85d943baed0affefcef1bde8042b20fc Mon Sep 17 00:00:00 2001 From: xiispace Date: Sat, 20 Jun 2026 10:01:51 +0800 Subject: [PATCH 08/10] Honor persisted blocked external access in MCP tab snapshots --- TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift index b760504a3..e87bb910c 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift @@ -48,7 +48,7 @@ enum MCPTabSnapshotProvider { @MainActor static func blockedExternalConnectionIds() -> Set { let connections = ConnectionStorage.shared.loadConnections() - return Set(connections.filter { $0.externalAccess == .blocked }.map(\.id)) + return Set(connections.filter { $0.resolvedExternalAccess == .blocked }.map(\.id)) } } From 114a110d20d66e0f6cc596f448cc1913f36b4471 Mon Sep 17 00:00:00 2001 From: xiispace Date: Sat, 20 Jun 2026 10:03:10 +0800 Subject: [PATCH 09/10] Restore MCP connection bridge while honoring resolved external access --- TablePro/Core/MCP/MCPConnectionBridge.swift | 138 ++++++++++++++++---- 1 file changed, 112 insertions(+), 26 deletions(-) diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index 91bc4cb5a..4be46fc1b 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -359,54 +359,140 @@ public actor MCPConnectionBridge { let cachedTables = await MainActor.run { SchemaService.shared.tables(for: connectionId) } + + let (driver, _) = try await resolveDriver(connectionId) + let tables: [TableInfo] if !cachedTables.isEmpty { tables = cachedTables } else { - let (driver, _) = try await resolveDriver(connectionId) tables = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { try await driver.fetchTables() } } - let resources = tables.map { table in - JsonValue.object([ - "uri": .string("tablepro://connection/\(connectionId.uuidString)/schema/\(table.name)"), + let limitedTables = Array(tables.prefix(100)) + + var tableSchemas: [JsonValue] = [] + for table in limitedTables { + let columns = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { + try await driver.fetchColumns(table: table.name) + } + + let jsonCols: [JsonValue] = columns.map { col in + .object([ + "name": .string(col.name), + "data_type": .string(col.dataType), + "is_nullable": .bool(col.isNullable), + "is_primary_key": .bool(col.isPrimaryKey) + ]) + } + + tableSchemas.append(.object([ "name": .string(table.name), - "mimeType": .string("application/json") - ]) + "type": .string(table.type.rawValue), + "columns": .array(jsonCols) + ])) } - return .object(["resources": .array(resources)]) - } - private func resolveConnection(_ connectionId: UUID) async throws -> DatabaseConnection { - let connection = await MainActor.run { - DatabaseManager.shared.connectionState(connectionId).connection + var result: [String: JsonValue] = ["tables": .array(tableSchemas)] + if tables.count > 100 { + result["truncated"] = .bool(true) + result["total_tables"] = .int(tables.count) } - guard let connection else { - throw MCPDataLayerError.notConnected(connectionId) + + return .object(result) + } + + func fetchHistoryResource( + connectionId: UUID, + limit: Int, + search: String?, + dateFilter: String? + ) async throws -> JsonValue { + let filter: DateFilter + switch dateFilter { + case "today": filter = .today + case "thisWeek": filter = .thisWeek + case "thisMonth": filter = .thisMonth + default: filter = .all + } + + let entries = await QueryHistoryManager.shared.fetchHistory( + limit: limit, + connectionId: connectionId, + searchText: search, + dateFilter: filter + ) + + let jsonEntries: [JsonValue] = entries.map { entry in + var obj: [String: JsonValue] = [ + "id": .string(entry.id.uuidString), + "query": .string(entry.query), + "database_name": .string(entry.databaseName), + "executed_at": .string(ISO8601DateFormatter().string(from: entry.executedAt)), + "execution_time_ms": .double(entry.executionTime * 1_000), + "row_count": .int(entry.rowCount), + "was_successful": .bool(entry.wasSuccessful) + ] + if let errorMsg = entry.errorMessage { + obj["error_message"] = .string(errorMsg) + } + return .object(obj) } - return connection + + return .object(["history": .array(jsonEntries)]) } - private func resolveDriver(_ connectionId: UUID) async throws -> (any DatabaseDriver, DatabaseType) { - let state = await MainActor.run { - DatabaseManager.shared.connectionState(connectionId) + private func resolveDriver(_ connectionId: UUID) async throws -> (DatabaseDriver, DatabaseType) { + let pending: DatabaseConnection? = await MainActor.run { + switch DatabaseManager.shared.connectionState(connectionId) { + case .live: return nil + case .stored(let connection): return connection + case .unknown: return nil + } } - guard let connection = state.connection else { - throw MCPDataLayerError.notConnected(connectionId) + if let pending { + try await connectIfNeeded(pending) } - guard let session = state.session, let driver = session.driver else { - throw MCPDataLayerError.notConnected(connectionId) + return try await MainActor.run { + switch DatabaseManager.shared.connectionState(connectionId) { + case .live(let driver, let session): + return (driver, session.connection.type) + case .stored, .unknown: + throw MCPDataLayerError.notConnected(connectionId) + } + } + } + + private func connectIfNeeded(_ connection: DatabaseConnection) async throws { + try await DatabaseManager.shared.ensureConnected(connection) + } + + private func resolveSession(_ connectionId: UUID) async throws -> ConnectionSession { + try await MainActor.run { + guard let session = DatabaseManager.shared.activeSessions[connectionId] else { + throw MCPDataLayerError.notConnected(connectionId) + } + return session + } + } + + private func resolveConnection(_ connectionId: UUID) async throws -> DatabaseConnection { + try await MainActor.run { + let connections = ConnectionStorage.shared.loadConnections() + guard let connection = connections.first(where: { $0.id == connectionId }) else { + throw MCPDataLayerError.invalidArgument("Connection not found: \(connectionId)") + } + return connection } - return (driver, connection.type) } - private static func stripTrailingSemicolons(_ sql: String) -> String { - var result = sql.trimmingCharacters(in: .whitespacesAndNewlines) + static func stripTrailingSemicolons(_ query: String) -> String { + var result = query.trimmingCharacters(in: .whitespacesAndNewlines) while result.hasSuffix(";") { - result.removeLast() - result = result.trimmingCharacters(in: .whitespacesAndNewlines) + result = String(result.dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) } return result } From 51baf68d3f623ac284014380775dc02d56b44da7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 20 Jun 2026 12:59:01 +0700 Subject: [PATCH 10/10] fix(connections): persist external client access level across save and reload (#1730) --- CHANGELOG.md | 1 + TablePro/Core/MCP/MCPAuthPolicy.swift | 4 +- TablePro/Core/MCP/MCPConnectionBridge.swift | 2 +- .../Tools/MCPTabSnapshotProvider.swift | 2 +- TablePro/Core/Storage/StoredConnection.swift | 12 ++ .../DatabaseConnectionExternalAccess.swift | 16 --- .../ViewModels/AdvancedPaneViewModel.swift | 3 +- ...ConnectionStorageExternalAccessTests.swift | 118 ++++++++++++++++++ .../AdvancedPaneViewModelTests.swift | 38 +++--- 9 files changed, 156 insertions(+), 40 deletions(-) delete mode 100644 TablePro/Models/Connection/DatabaseConnectionExternalAccess.swift create mode 100644 TableProTests/Core/Storage/ConnectionStorageExternalAccessTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0697c32b5..e220975ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Oracle connections no longer crash the app when the server sends a backend message the driver cannot decode; the query fails with a clear error and the connection reconnects. (#483) - MongoDB TLS handshake failures now report the actual cause instead of always blaming a cipher or protocol mismatch. (#1418) +- The External Clients access level no longer reverts to Read Only after saving and reopening a connection, so MCP clients keep the write access you granted. (#1730) ## [0.52.0] - 2026-06-19 diff --git a/TablePro/Core/MCP/MCPAuthPolicy.swift b/TablePro/Core/MCP/MCPAuthPolicy.swift index f35b1118f..0e4e65bb3 100644 --- a/TablePro/Core/MCP/MCPAuthPolicy.swift +++ b/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -263,14 +263,14 @@ public actor MCPAuthPolicy { let conn = session.connection return MCPConnectionAuthSnapshot( policy: conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, - externalAccess: conn.resolvedExternalAccess, + externalAccess: conn.externalAccess, name: conn.name, databaseType: conn.type.rawValue ) case .stored(let conn): return MCPConnectionAuthSnapshot( policy: conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, - externalAccess: conn.resolvedExternalAccess, + externalAccess: conn.externalAccess, name: conn.name, databaseType: conn.type.rawValue ) diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index 4be46fc1b..75eef53aa 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -10,7 +10,7 @@ public actor MCPConnectionBridge { func listConnections() async -> JsonValue { let (connections, activeSessions) = await MainActor.run { let conns = ConnectionStorage.shared.loadConnections() - .filter { $0.resolvedExternalAccess != .blocked } + .filter { $0.externalAccess != .blocked } let sessions = DatabaseManager.shared.activeSessions return (conns, sessions) } diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift index e87bb910c..b760504a3 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift @@ -48,7 +48,7 @@ enum MCPTabSnapshotProvider { @MainActor static func blockedExternalConnectionIds() -> Set { let connections = ConnectionStorage.shared.loadConnections() - return Set(connections.filter { $0.resolvedExternalAccess == .blocked }.map(\.id)) + return Set(connections.filter { $0.externalAccess == .blocked }.map(\.id)) } } diff --git a/TablePro/Core/Storage/StoredConnection.swift b/TablePro/Core/Storage/StoredConnection.swift index a83dc221e..42b29ac68 100644 --- a/TablePro/Core/Storage/StoredConnection.swift +++ b/TablePro/Core/Storage/StoredConnection.swift @@ -39,6 +39,9 @@ struct StoredConnection: Codable { // Safe mode level let safeModeLevel: String + // External client (MCP) access level + let externalAccess: String + // AI policy let aiPolicy: String? @@ -132,6 +135,9 @@ struct StoredConnection: Codable { // Safe mode level self.safeModeLevel = connection.safeModeLevel.rawValue + // External client (MCP) access level + self.externalAccess = connection.externalAccess.rawValue + // AI policy self.aiPolicy = connection.aiPolicy?.rawValue self.aiRules = connection.aiRules @@ -191,6 +197,7 @@ struct StoredConnection: Codable { case sslMode, sslCaCertificatePath, sslClientCertificatePath, sslClientKeyPath case color, tagId, groupId, sshProfileId case safeModeLevel + case externalAccess case isReadOnly // Legacy key for migration reading only case aiPolicy case aiRules @@ -235,6 +242,7 @@ struct StoredConnection: Codable { try container.encodeIfPresent(groupId, forKey: .groupId) try container.encodeIfPresent(sshProfileId, forKey: .sshProfileId) try container.encode(safeModeLevel, forKey: .safeModeLevel) + try container.encode(externalAccess, forKey: .externalAccess) try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy) try container.encodeIfPresent(aiRules, forKey: .aiRules) try container.encodeIfPresent(aiAlwaysAllowedTools, forKey: .aiAlwaysAllowedTools) @@ -300,6 +308,9 @@ struct StoredConnection: Codable { let wasReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false safeModeLevel = wasReadOnly ? SafeModeLevel.readOnly.rawValue : SafeModeLevel.silent.rawValue } + externalAccess = try container.decodeIfPresent( + String.self, forKey: .externalAccess + ) ?? ExternalAccessLevel.readOnly.rawValue aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy) aiRules = try container.decodeIfPresent(String.self, forKey: .aiRules) aiAlwaysAllowedTools = try container.decodeIfPresent([String].self, forKey: .aiAlwaysAllowedTools) @@ -416,6 +427,7 @@ struct StoredConnection: Codable { aiPolicy: parsedAIPolicy, aiRules: aiRules, aiAlwaysAllowedTools: Set(aiAlwaysAllowedTools ?? []), + externalAccess: ExternalAccessLevel(rawValue: externalAccess) ?? .readOnly, redisDatabase: redisDatabase, startupCommands: startupCommands, sortOrder: sortOrder, diff --git a/TablePro/Models/Connection/DatabaseConnectionExternalAccess.swift b/TablePro/Models/Connection/DatabaseConnectionExternalAccess.swift deleted file mode 100644 index 71f4d0bf2..000000000 --- a/TablePro/Models/Connection/DatabaseConnectionExternalAccess.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// DatabaseConnectionExternalAccess.swift -// TablePro -// - -import Foundation - -extension DatabaseConnection { - static let persistedExternalAccessFieldKey = "externalAccess" - - var resolvedExternalAccess: ExternalAccessLevel { - additionalFields[Self.persistedExternalAccessFieldKey] - .flatMap(ExternalAccessLevel.init(rawValue:)) - ?? externalAccess - } -} diff --git a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift index 668a7ee85..06c2614fa 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift @@ -75,7 +75,7 @@ final class AdvancedPaneViewModel { startupCommands = connection.startupCommands ?? "" preConnectScript = connection.preConnectScript ?? "" aiPolicy = connection.aiPolicy - externalAccess = connection.resolvedExternalAccess + externalAccess = connection.externalAccess localOnly = connection.localOnly } @@ -83,6 +83,5 @@ final class AdvancedPaneViewModel { for (key, value) in additionalFieldValues { fields[key] = value } - fields[DatabaseConnection.persistedExternalAccessFieldKey] = externalAccess.rawValue } } diff --git a/TableProTests/Core/Storage/ConnectionStorageExternalAccessTests.swift b/TableProTests/Core/Storage/ConnectionStorageExternalAccessTests.swift new file mode 100644 index 000000000..74f7608fb --- /dev/null +++ b/TableProTests/Core/Storage/ConnectionStorageExternalAccessTests.swift @@ -0,0 +1,118 @@ +// +// ConnectionStorageExternalAccessTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +@testable import TablePro +import Testing + +@Suite("ConnectionStorage External Access") +@MainActor +struct ConnectionStorageExternalAccessTests { + private let storage: ConnectionStorage + + init() { + let unique = UUID().uuidString + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("connections_\(unique).json") + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let defaultsName = "com.TablePro.tests.ConnectionStorage.ExternalAccess.\(unique)" + let syncName = "com.TablePro.tests.Sync.ExternalAccess.\(unique)" + guard let defaults = UserDefaults(suiteName: defaultsName), + let syncDefaults = UserDefaults(suiteName: syncName) else { + fatalError("UserDefaults suite creation failed in test setup") + } + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + let tracker = SyncChangeTracker(metadataStorage: metadata) + self.storage = ConnectionStorage( + fileURL: fileURL, + userDefaults: defaults, + syncTracker: tracker + ) + } + + @Test("round-trip preserves external access", arguments: ExternalAccessLevel.allCases) + func roundTripExternalAccess(_ level: ExternalAccessLevel) { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Test", + type: .postgresql, + externalAccess: level + ) + + storage.addConnection(connection) + defer { storage.deleteConnection(connection) } + + let loaded = storage.loadConnections().first { $0.id == id } + #expect(loaded?.externalAccess == level) + } + + @Test("external access survives mutate-and-update cycle") + func updateExternalAccess() { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Test", + type: .mysql, + externalAccess: .readOnly + ) + + storage.addConnection(connection) + defer { storage.deleteConnection(connection) } + + var updated = connection + updated.externalAccess = .readWrite + storage.updateConnection(updated) + + let loaded = storage.loadConnections().first { $0.id == id } + #expect(loaded?.externalAccess == .readWrite) + } + + @Test("legacy records without externalAccess default to readOnly") + func legacyDecodeDefaultsToReadOnly() throws { + let stored = try JSONDecoder().decode( + StoredConnection.self, + from: Data(Self.legacyJSONWithoutExternalAccess.utf8) + ) + #expect(stored.toConnection().externalAccess == .readOnly) + } + + private static let legacyJSONWithoutExternalAccess = """ + { + "id": "11111111-2222-3333-4444-555555555555", + "name": "Legacy", + "host": "localhost", + "port": 3306, + "database": "test", + "username": "root", + "type": "MySQL", + "sshEnabled": false, + "sshHost": "", + "sshUsername": "", + "sshAuthMethod": "password", + "sshPrivateKeyPath": "", + "sshAgentSocketPath": "", + "sslMode": "disabled", + "sslCaCertificatePath": "", + "sslClientCertificatePath": "", + "sslClientKeyPath": "", + "color": "None", + "safeModeLevel": "silent", + "sortOrder": 0, + "localOnly": false, + "isSample": false, + "isFavorite": false, + "totpMode": "none", + "totpAlgorithm": "sha1", + "totpDigits": 6, + "totpPeriod": 30 + } + """ +} diff --git a/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift b/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift index 42ad6db33..51479df67 100644 --- a/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift +++ b/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift @@ -1,32 +1,34 @@ -import Testing -@testable import TablePro +// +// AdvancedPaneViewModelTests.swift +// TableProTests +// + +import Foundation import TableProPluginKit +@testable import TablePro +import Testing -@Suite("Advanced pane external access persistence") +@Suite("Advanced pane external access") @MainActor struct AdvancedPaneViewModelTests { - @Test("Writes external access into persisted fields") - func writesExternalAccessIntoPersistedFields() { + @Test("Loads external access from the connection") + func loadsExternalAccessFromConnection() { + let connection = DatabaseConnection(name: "Test", externalAccess: .readWrite) let viewModel = AdvancedPaneViewModel() - viewModel.externalAccess = .readWrite - var fields: [String: String] = [:] - viewModel.write(into: &fields) + viewModel.load(from: connection) - #expect(fields["externalAccess"] == ExternalAccessLevel.readWrite.rawValue) + #expect(viewModel.externalAccess == .readWrite) } - @Test("Loads external access from persisted fields") - func loadsExternalAccessFromPersistedFields() { - let connection = DatabaseConnection( - name: "Test", - externalAccess: .readOnly, - additionalFields: ["externalAccess": ExternalAccessLevel.readWrite.rawValue] - ) + @Test("Does not leak external access into plugin additional fields") + func doesNotWriteExternalAccessIntoAdditionalFields() { let viewModel = AdvancedPaneViewModel() + viewModel.externalAccess = .readWrite - viewModel.load(from: connection) + var fields: [String: String] = [:] + viewModel.write(into: &fields) - #expect(viewModel.externalAccess == .readWrite) + #expect(fields["externalAccess"] == nil) } }