From fc1f25930008ed4e8471f8f37089d9646a99f894 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 04:26:08 +0700 Subject: [PATCH 01/54] refactor(mcp): phase 1 wire layer + phase 3 session/auth/ratelimit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire layer (TablePro/Core/MCP/Wire/) — pure value types with no transport awareness: tagged JsonRpcMessage enum (4 cases), JsonRpcId supporting null explicitly, JsonRpcCodec, strict-CRLF HttpRequestParser, SseEncoder/Decoder, typed JsonRpcDecodingError. Replaces ad-hoc message types and tolerant HTTP parser. Session layer (Session/, Auth/, RateLimit/) — actor-based with eviction broadcast: MCPSessionStore actor with multicast events stream, idle eviction under MCPClock abstraction, MCPBearerTokenAuthenticator with SHA-256 token fingerprint, rate limiter rekeyed on (clientAddress, principalFingerprint) to fix the localhost auth-DoS issue. Idle timeout raised from 5 to 15 min. Old types renamed with Legacy prefix to coexist during the rewrite — they will be deleted in phase 6 once all callers are migrated. --- TablePro/Core/MCP/Auth/MCPAuthDecision.swift | 58 ++++ TablePro/Core/MCP/Auth/MCPAuthenticator.swift | 13 + .../Auth/MCPBearerTokenAuthenticator.swift | 162 +++++++++++ TablePro/Core/MCP/Auth/MCPPrincipal.swift | 42 +++ TablePro/Core/MCP/LegacyMCPRateLimiter.swift | 94 ++++++ TablePro/Core/MCP/LegacyMCPSession.swift | 95 +++++++ TablePro/Core/MCP/MCPMessageTypes.swift | 2 +- TablePro/Core/MCP/MCPServer.swift | 12 +- TablePro/Core/MCP/MCPServerManager.swift | 2 +- TablePro/Core/MCP/MCPSessionPhase.swift | 4 +- .../Core/MCP/RateLimit/MCPRateLimiter.swift | 110 +++++++ .../Core/MCP/Routes/MCPProtocolHandler.swift | 10 +- TablePro/Core/MCP/Session/MCPClock.swift | 18 ++ TablePro/Core/MCP/Session/MCPSession.swift | 99 +++++++ .../Core/MCP/Session/MCPSessionEvent.swift | 6 + TablePro/Core/MCP/Session/MCPSessionId.swift | 17 ++ .../Core/MCP/Session/MCPSessionPolicy.swift | 19 ++ .../Core/MCP/Session/MCPSessionState.swift | 27 ++ .../Core/MCP/Session/MCPSessionStore.swift | 144 ++++++++++ TablePro/Core/MCP/Wire/HttpRequestHead.swift | 96 +++++++ .../Core/MCP/Wire/HttpRequestParser.swift | 196 +++++++++++++ .../Core/MCP/Wire/HttpResponseEncoder.swift | 25 ++ TablePro/Core/MCP/Wire/HttpResponseHead.swift | 36 +++ TablePro/Core/MCP/Wire/JsonRpcCodec.swift | 17 ++ TablePro/Core/MCP/Wire/JsonRpcError.swift | 91 ++++++ TablePro/Core/MCP/Wire/JsonRpcErrorCode.swift | 21 ++ TablePro/Core/MCP/Wire/JsonRpcId.swift | 43 +++ TablePro/Core/MCP/Wire/JsonRpcMessage.swift | 269 ++++++++++++++++++ TablePro/Core/MCP/Wire/JsonRpcVersion.swift | 15 + TablePro/Core/MCP/Wire/JsonValue.swift | 162 +++++++++++ TablePro/Core/MCP/Wire/SseDecoder.swift | 129 +++++++++ TablePro/Core/MCP/Wire/SseEncoder.swift | 58 ++++ TablePro/Core/MCP/Wire/SseFrame.swift | 15 + .../MCPBearerTokenAuthenticatorTests.swift | 228 +++++++++++++++ .../Core/MCP/Helpers/MCPTestClock.swift | 62 ++++ .../Core/MCP/LegacyMCPRateLimiterTests.swift | 216 ++++++++++++++ .../MCP/RateLimit/MCPRateLimiterTests.swift | 143 ++++++++++ .../MCP/Session/MCPSessionStoreTests.swift | 182 ++++++++++++ .../Core/MCP/Session/MCPSessionTests.swift | 95 +++++++ .../MCP/Wire/HttpRequestParserTests.swift | 129 +++++++++ .../Core/MCP/Wire/JsonRpcIdTests.swift | 60 ++++ .../Core/MCP/Wire/JsonRpcMessageTests.swift | 206 ++++++++++++++ .../MCP/Wire/SseEncoderDecoderTests.swift | 104 +++++++ .../Views/Main/TableRowsMutationTests.swift | 16 +- 44 files changed, 3525 insertions(+), 23 deletions(-) create mode 100644 TablePro/Core/MCP/Auth/MCPAuthDecision.swift create mode 100644 TablePro/Core/MCP/Auth/MCPAuthenticator.swift create mode 100644 TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift create mode 100644 TablePro/Core/MCP/Auth/MCPPrincipal.swift create mode 100644 TablePro/Core/MCP/LegacyMCPRateLimiter.swift create mode 100644 TablePro/Core/MCP/LegacyMCPSession.swift create mode 100644 TablePro/Core/MCP/RateLimit/MCPRateLimiter.swift create mode 100644 TablePro/Core/MCP/Session/MCPClock.swift create mode 100644 TablePro/Core/MCP/Session/MCPSession.swift create mode 100644 TablePro/Core/MCP/Session/MCPSessionEvent.swift create mode 100644 TablePro/Core/MCP/Session/MCPSessionId.swift create mode 100644 TablePro/Core/MCP/Session/MCPSessionPolicy.swift create mode 100644 TablePro/Core/MCP/Session/MCPSessionState.swift create mode 100644 TablePro/Core/MCP/Session/MCPSessionStore.swift create mode 100644 TablePro/Core/MCP/Wire/HttpRequestHead.swift create mode 100644 TablePro/Core/MCP/Wire/HttpRequestParser.swift create mode 100644 TablePro/Core/MCP/Wire/HttpResponseEncoder.swift create mode 100644 TablePro/Core/MCP/Wire/HttpResponseHead.swift create mode 100644 TablePro/Core/MCP/Wire/JsonRpcCodec.swift create mode 100644 TablePro/Core/MCP/Wire/JsonRpcError.swift create mode 100644 TablePro/Core/MCP/Wire/JsonRpcErrorCode.swift create mode 100644 TablePro/Core/MCP/Wire/JsonRpcId.swift create mode 100644 TablePro/Core/MCP/Wire/JsonRpcMessage.swift create mode 100644 TablePro/Core/MCP/Wire/JsonRpcVersion.swift create mode 100644 TablePro/Core/MCP/Wire/JsonValue.swift create mode 100644 TablePro/Core/MCP/Wire/SseDecoder.swift create mode 100644 TablePro/Core/MCP/Wire/SseEncoder.swift create mode 100644 TablePro/Core/MCP/Wire/SseFrame.swift create mode 100644 TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift create mode 100644 TableProTests/Core/MCP/Helpers/MCPTestClock.swift create mode 100644 TableProTests/Core/MCP/LegacyMCPRateLimiterTests.swift create mode 100644 TableProTests/Core/MCP/RateLimit/MCPRateLimiterTests.swift create mode 100644 TableProTests/Core/MCP/Session/MCPSessionStoreTests.swift create mode 100644 TableProTests/Core/MCP/Session/MCPSessionTests.swift create mode 100644 TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift create mode 100644 TableProTests/Core/MCP/Wire/JsonRpcIdTests.swift create mode 100644 TableProTests/Core/MCP/Wire/JsonRpcMessageTests.swift create mode 100644 TableProTests/Core/MCP/Wire/SseEncoderDecoderTests.swift diff --git a/TablePro/Core/MCP/Auth/MCPAuthDecision.swift b/TablePro/Core/MCP/Auth/MCPAuthDecision.swift new file mode 100644 index 000000000..faa79a8ea --- /dev/null +++ b/TablePro/Core/MCP/Auth/MCPAuthDecision.swift @@ -0,0 +1,58 @@ +import Foundation + +public enum MCPAuthDecision: Sendable { + case allow(MCPPrincipal) + case deny(MCPAuthDenialReason) +} + +public struct MCPAuthDenialReason: Sendable, Equatable { + public let httpStatus: Int + public let challenge: String? + public let logMessage: String + + public init(httpStatus: Int, challenge: String?, logMessage: String) { + self.httpStatus = httpStatus + self.challenge = challenge + self.logMessage = logMessage + } + + public static func unauthenticated(reason: String) -> Self { + Self( + httpStatus: 401, + challenge: "Bearer realm=\"TablePro MCP\"", + logMessage: reason + ) + } + + public static func tokenExpired() -> Self { + Self( + httpStatus: 401, + challenge: "Bearer realm=\"TablePro MCP\", error=\"invalid_token\", error_description=\"token_expired\"", + logMessage: "token_expired" + ) + } + + public static func tokenInvalid(reason: String) -> Self { + Self( + httpStatus: 401, + challenge: "Bearer realm=\"TablePro MCP\", error=\"invalid_token\"", + logMessage: reason + ) + } + + public static func forbidden(reason: String) -> Self { + Self( + httpStatus: 403, + challenge: nil, + logMessage: reason + ) + } + + public static func rateLimited() -> Self { + Self( + httpStatus: 429, + challenge: nil, + logMessage: "rate_limited" + ) + } +} diff --git a/TablePro/Core/MCP/Auth/MCPAuthenticator.swift b/TablePro/Core/MCP/Auth/MCPAuthenticator.swift new file mode 100644 index 000000000..4aa761315 --- /dev/null +++ b/TablePro/Core/MCP/Auth/MCPAuthenticator.swift @@ -0,0 +1,13 @@ +import Foundation + +public enum MCPClientAddress: Sendable, Equatable, Hashable { + case loopback + case remote(String) +} + +public protocol MCPAuthenticator: Sendable { + func authenticate( + authorizationHeader: String?, + clientAddress: MCPClientAddress + ) async -> MCPAuthDecision +} diff --git a/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift new file mode 100644 index 000000000..7962b88f7 --- /dev/null +++ b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift @@ -0,0 +1,162 @@ +import CryptoKit +import Foundation +import os + +public struct MCPValidatedToken: Sendable, Equatable { + public let label: String? + public let scopes: Set + public let issuedAt: Date + public let expiresAt: Date? + + public init(label: String?, scopes: Set, issuedAt: Date, expiresAt: Date?) { + self.label = label + self.scopes = scopes + self.issuedAt = issuedAt + self.expiresAt = expiresAt + } +} + +public enum MCPTokenValidationError: Error, Sendable, Equatable { + case unknownToken + case expired + case revoked +} + +public protocol MCPTokenStoreProtocol: Sendable { + func validateBearerToken(_ token: String) async -> Result +} + +extension MCPTokenStore: MCPTokenStoreProtocol {} + +internal extension MCPTokenStore { + func validateBearerToken(_ bearerToken: String) async -> Result { + guard let authToken = self.validate(bearerToken: bearerToken) else { + return .failure(.unknownToken) + } + if authToken.isExpired { + return .failure(.expired) + } + if !authToken.isActive { + return .failure(.revoked) + } + let validated = MCPValidatedToken( + label: authToken.name, + scopes: Self.mcpScopes(for: authToken.permissions), + issuedAt: authToken.createdAt, + expiresAt: authToken.expiresAt + ) + return .success(validated) + } + + private static func mcpScopes(for permissions: TokenPermissions) -> Set { + switch permissions { + case .readOnly: + return [.toolsRead, .resourcesRead] + case .readWrite: + return [.toolsRead, .toolsWrite, .resourcesRead] + case .fullAccess: + return [.toolsRead, .toolsWrite, .resourcesRead, .admin] + } + } +} + +public actor MCPBearerTokenAuthenticator: MCPAuthenticator { + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Auth") + + private let tokenStore: any MCPTokenStoreProtocol + private let rateLimiter: MCPRateLimiter + + public init(tokenStore: any MCPTokenStoreProtocol, rateLimiter: MCPRateLimiter) { + self.tokenStore = tokenStore + self.rateLimiter = rateLimiter + } + + public func authenticate( + authorizationHeader: String?, + clientAddress: MCPClientAddress + ) async -> MCPAuthDecision { + guard let header = authorizationHeader, !header.isEmpty else { + let key = MCPRateLimitKey(clientAddress: clientAddress, principalFingerprint: nil) + if await rateLimiter.isLocked(key: key) { + Self.logger.warning("Auth rejected (rate limited, missing header)") + return .deny(.rateLimited()) + } + Self.logger.info("Auth missing Authorization header") + return .deny(.unauthenticated(reason: "missing_authorization_header")) + } + + guard let token = Self.parseBearerToken(header) else { + let key = MCPRateLimitKey(clientAddress: clientAddress, principalFingerprint: nil) + if await rateLimiter.isLocked(key: key) { + return .deny(.rateLimited()) + } + _ = await rateLimiter.recordAttempt(key: key, success: false) + Self.logger.info("Auth invalid Authorization scheme") + return .deny(.unauthenticated(reason: "invalid_authorization_scheme")) + } + + let fingerprint = Self.fingerprint(of: token) + let principalKey = MCPRateLimitKey( + clientAddress: clientAddress, + principalFingerprint: fingerprint + ) + + if await rateLimiter.isLocked(key: principalKey) { + Self.logger.warning( + "Auth rate limited fingerprint=\(fingerprint, privacy: .public)" + ) + return .deny(.rateLimited()) + } + + let validation = await tokenStore.validateBearerToken(token) + switch validation { + case .failure(let error): + let verdict = await rateLimiter.recordAttempt(key: principalKey, success: false) + if case .lockedUntil = verdict { + return .deny(.rateLimited()) + } + switch error { + case .unknownToken: + Self.logger.info("Auth unknown token fingerprint=\(fingerprint, privacy: .public)") + return .deny(.tokenInvalid(reason: "unknown_token")) + case .expired: + Self.logger.info("Auth expired token fingerprint=\(fingerprint, privacy: .public)") + return .deny(.tokenExpired()) + case .revoked: + Self.logger.info("Auth revoked token fingerprint=\(fingerprint, privacy: .public)") + return .deny(.tokenInvalid(reason: "token_revoked")) + } + + case .success(let validated): + _ = await rateLimiter.recordAttempt(key: principalKey, success: true) + let principal = MCPPrincipal( + tokenFingerprint: fingerprint, + scopes: validated.scopes, + metadata: MCPPrincipalMetadata( + label: validated.label, + issuedAt: validated.issuedAt, + expiresAt: validated.expiresAt + ) + ) + Self.logger.info("Auth allowed fingerprint=\(fingerprint, privacy: .public)") + return .allow(principal) + } + } + + internal static func parseBearerToken(_ header: String) -> String? { + let trimmed = header.trimmingCharacters(in: .whitespacesAndNewlines) + guard let spaceIndex = trimmed.firstIndex(of: " ") else { return nil } + let scheme = trimmed[trimmed.startIndex.. String { + guard let data = token.data(using: .utf8) else { return "" } + let digest = SHA256.hash(data: data) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return String(hex.prefix(8)) + } +} diff --git a/TablePro/Core/MCP/Auth/MCPPrincipal.swift b/TablePro/Core/MCP/Auth/MCPPrincipal.swift new file mode 100644 index 000000000..1efe5d87d --- /dev/null +++ b/TablePro/Core/MCP/Auth/MCPPrincipal.swift @@ -0,0 +1,42 @@ +import Foundation + +public enum MCPScope: String, Sendable, Equatable, Hashable, CaseIterable { + case toolsRead = "tools:read" + case toolsWrite = "tools:write" + case resourcesRead = "resources:read" + case admin +} + +public struct MCPPrincipalMetadata: Sendable, Equatable { + public let label: String? + public let issuedAt: Date + public let expiresAt: Date? + + public init(label: String?, issuedAt: Date, expiresAt: Date?) { + self.label = label + self.issuedAt = issuedAt + self.expiresAt = expiresAt + } +} + +public struct MCPPrincipal: Sendable, Equatable, Hashable { + public let tokenFingerprint: String + public let scopes: Set + public let metadata: MCPPrincipalMetadata + + public init(tokenFingerprint: String, scopes: Set, metadata: MCPPrincipalMetadata) { + self.tokenFingerprint = tokenFingerprint + self.scopes = scopes + self.metadata = metadata + } + + public static func == (lhs: MCPPrincipal, rhs: MCPPrincipal) -> Bool { + lhs.tokenFingerprint == rhs.tokenFingerprint + && lhs.scopes == rhs.scopes + && lhs.metadata == rhs.metadata + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(tokenFingerprint) + } +} diff --git a/TablePro/Core/MCP/LegacyMCPRateLimiter.swift b/TablePro/Core/MCP/LegacyMCPRateLimiter.swift new file mode 100644 index 000000000..b7996d406 --- /dev/null +++ b/TablePro/Core/MCP/LegacyMCPRateLimiter.swift @@ -0,0 +1,94 @@ +import Foundation +import os + +actor LegacyMCPRateLimiter { + enum AuthRateResult: Sendable { + case allowed + case rateLimited(retryAfter: Duration) + } + + private struct FailureRecord { + var consecutiveFailures: Int + var lockedUntil: ContinuousClock.Instant? + var lastUpdated: ContinuousClock.Instant + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "LegacyMCPRateLimiter") + + private static let staleEntryThreshold: Duration = .seconds(600) + private static let cleanupInterval: Duration = .seconds(300) + + private var records: [String: FailureRecord] = [:] + private var lastCleanup: ContinuousClock.Instant = .now + + func checkAndRecord(ip: String, success: Bool) -> AuthRateResult { + cleanupStaleEntriesIfNeeded() + + let now = ContinuousClock.now + + if let record = records[ip], let lockedUntil = record.lockedUntil, now < lockedUntil { + let remaining = lockedUntil - now + return .rateLimited(retryAfter: remaining) + } + + guard !success else { + records.removeValue(forKey: ip) + return .allowed + } + + var record = records[ip] ?? FailureRecord(consecutiveFailures: 0, lockedUntil: nil, lastUpdated: now) + record.consecutiveFailures += 1 + record.lastUpdated = now + + let lockoutDuration = lockoutDuration(forFailureCount: record.consecutiveFailures) + if let lockout = lockoutDuration { + record.lockedUntil = now + lockout + records[ip] = record + return .rateLimited(retryAfter: lockout) + } + + record.lockedUntil = nil + records[ip] = record + return .allowed + } + + func isLockedOut(ip: String) -> AuthRateResult { + let now = ContinuousClock.now + guard let record = records[ip], let lockedUntil = record.lockedUntil, now < lockedUntil else { + return .allowed + } + return .rateLimited(retryAfter: lockedUntil - now) + } + + private func lockoutDuration(forFailureCount count: Int) -> Duration? { + switch count { + case 1: + return nil + case 2: + return .seconds(1) + case 3: + return .seconds(5) + case 4: + return .seconds(30) + default: + return .seconds(300) + } + } + + private func cleanupStaleEntriesIfNeeded() { + let now = ContinuousClock.now + guard now - lastCleanup > Self.cleanupInterval else { return } + + lastCleanup = now + let threshold = now - Self.staleEntryThreshold + + let staleKeys = records.filter { $0.value.lastUpdated < threshold }.map(\.key) + for key in staleKeys { + records.removeValue(forKey: key) + } + + if !staleKeys.isEmpty { + Self.logger.info("Cleaned up \(staleKeys.count) stale rate limit entries") + } + } +} diff --git a/TablePro/Core/MCP/LegacyMCPSession.swift b/TablePro/Core/MCP/LegacyMCPSession.swift new file mode 100644 index 000000000..8e60c1ba8 --- /dev/null +++ b/TablePro/Core/MCP/LegacyMCPSession.swift @@ -0,0 +1,95 @@ +import Foundation +import Network + +actor LegacyMCPSession { + let id: String + let createdAt: ContinuousClock.Instant + + var lastActivityAt: ContinuousClock.Instant + private(set) var phase: MCPSessionPhase = .created + var clientInfo: LegacyMCPClientInfo? + var sseConnection: NWConnection? + var runningTasks: [JSONRPCId: Task] = [:] + private(set) var eventCounter: Int = 0 + private(set) var remoteAddress: String? + + var authenticatedTokenId: UUID? { + if case .active(let tokenId, _) = phase { return tokenId } + return nil + } + + var tokenName: String? { + if case .active(_, let tokenName) = phase { return tokenName } + return nil + } + + init() { + self.id = UUID().uuidString + let now = ContinuousClock.now + self.createdAt = now + self.lastActivityAt = now + } + + func markActive() { + lastActivityAt = .now + } + + func cancelAllTasks() { + for (_, task) in runningTasks { + task.cancel() + } + runningTasks.removeAll() + } + + func transition(to next: MCPSessionPhase) throws { + guard isValidTransition(from: phase, to: next) else { + throw MCPError.invalidRequest( + "Invalid session phase transition from \(phase) to \(next)" + ) + } + phase = next + } + + private func isValidTransition(from current: MCPSessionPhase, to next: MCPSessionPhase) -> Bool { + switch (current, next) { + case (.created, .initializing), + (.created, .active), + (.created, .terminated), + (.initializing, .active), + (.initializing, .terminated), + (.active, .terminated): + return true + default: + return false + } + } + + func setClientInfo(_ info: LegacyMCPClientInfo?) { + clientInfo = info + } + + func setRemoteAddress(_ address: String?) { + remoteAddress = address + } + + func setSSEConnection(_ connection: NWConnection?) { + sseConnection = connection + } + + func cancelSSEConnection() { + sseConnection?.cancel() + } + + func addRunningTask(_ id: JSONRPCId, task: Task) { + runningTasks[id] = task + } + + func removeRunningTask(_ id: JSONRPCId) -> Task? { + runningTasks.removeValue(forKey: id) + } + + func nextEventId() -> String { + eventCounter += 1 + return String(eventCounter) + } +} diff --git a/TablePro/Core/MCP/MCPMessageTypes.swift b/TablePro/Core/MCP/MCPMessageTypes.swift index 01ae77a47..f314d190c 100644 --- a/TablePro/Core/MCP/MCPMessageTypes.swift +++ b/TablePro/Core/MCP/MCPMessageTypes.swift @@ -360,7 +360,7 @@ extension MCPError: LocalizedError { var errorDescription: String? { message } } -struct MCPClientInfo: Codable, Sendable { +struct LegacyMCPClientInfo: Codable, Sendable { let name: String let version: String? } diff --git a/TablePro/Core/MCP/MCPServer.swift b/TablePro/Core/MCP/MCPServer.swift index a499d6581..19258e1f1 100644 --- a/TablePro/Core/MCP/MCPServer.swift +++ b/TablePro/Core/MCP/MCPServer.swift @@ -24,13 +24,13 @@ actor MCPServer { private var allowRemoteAccess: Bool = false private var listener: NWListener? - private var sessions: [String: MCPSession] = [:] + private var sessions: [String: LegacyMCPSession] = [:] private var cleanupTask: Task? private let stateCallback: @Sendable (MCPServerState) -> Void private var router: MCPRouter? private(set) var tokenStore: MCPTokenStore? - private(set) var rateLimiter: MCPRateLimiter? + private(set) var rateLimiter: LegacyMCPRateLimiter? private(set) var toolCallHandler: (@Sendable (String, JSONValue?, String, MCPAuthToken?) async throws -> MCPToolResult)? private(set) var resourceReadHandler: (@Sendable (String, String) async throws -> MCPResourceReadResult)? @@ -48,7 +48,7 @@ actor MCPServer { self.tokenStore = store } - func setRateLimiter(_ limiter: MCPRateLimiter) { + func setRateLimiter(_ limiter: LegacyMCPRateLimiter) { self.rateLimiter = limiter } @@ -339,19 +339,19 @@ actor MCPServer { } } - func createSession() -> MCPSession? { + func createSession() -> LegacyMCPSession? { guard sessions.count < Self.maxSessions else { Self.logger.warning("Maximum session limit reached (\(Self.maxSessions))") return nil } - let session = MCPSession() + let session = LegacyMCPSession() sessions[session.id] = session Self.logger.info("Created session \(session.id) (total: \(self.sessions.count))") return session } - func session(for sessionId: String) -> MCPSession? { + func session(for sessionId: String) -> LegacyMCPSession? { sessions[sessionId] } diff --git a/TablePro/Core/MCP/MCPServerManager.swift b/TablePro/Core/MCP/MCPServerManager.swift index e0616748b..37b676234 100644 --- a/TablePro/Core/MCP/MCPServerManager.swift +++ b/TablePro/Core/MCP/MCPServerManager.swift @@ -58,7 +58,7 @@ final class MCPServerManager { await newTokenStore.loadFromDisk() self.tokenStore = newTokenStore - let rateLimiter = MCPRateLimiter() + let rateLimiter = LegacyMCPRateLimiter() let bridge = MCPConnectionBridge() let authPolicy = MCPAuthPolicy() diff --git a/TablePro/Core/MCP/MCPSessionPhase.swift b/TablePro/Core/MCP/MCPSessionPhase.swift index fa502c9f5..795c8c67b 100644 --- a/TablePro/Core/MCP/MCPSessionPhase.swift +++ b/TablePro/Core/MCP/MCPSessionPhase.swift @@ -1,6 +1,6 @@ import Foundation -enum MCPSessionTerminationReason: Sendable, Equatable { +enum LegacyMCPSessionTerminationReason: Sendable, Equatable { case removed case idleTimeout case serverStopped @@ -11,7 +11,7 @@ enum MCPSessionPhase: Sendable, Equatable { case created case initializing case active(tokenId: UUID?, tokenName: String?) - case terminated(reason: MCPSessionTerminationReason) + case terminated(reason: LegacyMCPSessionTerminationReason) var isActive: Bool { if case .active = self { return true } diff --git a/TablePro/Core/MCP/RateLimit/MCPRateLimiter.swift b/TablePro/Core/MCP/RateLimit/MCPRateLimiter.swift new file mode 100644 index 000000000..4ed826141 --- /dev/null +++ b/TablePro/Core/MCP/RateLimit/MCPRateLimiter.swift @@ -0,0 +1,110 @@ +import Foundation +import os + +public struct MCPRateLimitKey: Sendable, Equatable, Hashable { + public let clientAddress: MCPClientAddress + public let principalFingerprint: String? + + public init(clientAddress: MCPClientAddress, principalFingerprint: String?) { + self.clientAddress = clientAddress + self.principalFingerprint = principalFingerprint + } +} + +public struct MCPRateLimitPolicy: Sendable, Equatable { + public let maxFailedAttempts: Int + public let windowDuration: Duration + public let lockoutDuration: Duration + + public init(maxFailedAttempts: Int, windowDuration: Duration, lockoutDuration: Duration) { + self.maxFailedAttempts = maxFailedAttempts + self.windowDuration = windowDuration + self.lockoutDuration = lockoutDuration + } + + public static let standard = MCPRateLimitPolicy( + maxFailedAttempts: 5, + windowDuration: .seconds(60), + lockoutDuration: .seconds(300) + ) +} + +public enum MCPRateLimitVerdict: Sendable, Equatable { + case allowed + case lockedUntil(Date) +} + +public actor MCPRateLimiter { + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.RateLimit") + + private struct Bucket { + var failureTimestamps: [Date] + var lockedUntil: Date? + } + + private let policy: MCPRateLimitPolicy + private let clock: any MCPClock + private var buckets: [MCPRateLimitKey: Bucket] = [:] + + public init(policy: MCPRateLimitPolicy = .standard, clock: any MCPClock = MCPSystemClock()) { + self.policy = policy + self.clock = clock + } + + public func recordAttempt(key: MCPRateLimitKey, success: Bool) async -> MCPRateLimitVerdict { + let now = await clock.now() + + if let lockedUntil = buckets[key]?.lockedUntil, lockedUntil > now { + return .lockedUntil(lockedUntil) + } + + if success { + buckets.removeValue(forKey: key) + return .allowed + } + + var bucket = buckets[key] ?? Bucket(failureTimestamps: [], lockedUntil: nil) + let windowStart = now.addingTimeInterval(-Self.seconds(of: policy.windowDuration)) + bucket.failureTimestamps.removeAll { $0 < windowStart } + bucket.failureTimestamps.append(now) + + if bucket.failureTimestamps.count >= policy.maxFailedAttempts { + let lockUntil = now.addingTimeInterval(Self.seconds(of: policy.lockoutDuration)) + bucket.lockedUntil = lockUntil + buckets[key] = bucket + Self.logger.warning( + "Rate limit lockout \(Self.describe(key), privacy: .public) until \(lockUntil, privacy: .public)" + ) + return .lockedUntil(lockUntil) + } + + bucket.lockedUntil = nil + buckets[key] = bucket + return .allowed + } + + public func isLocked(key: MCPRateLimitKey) async -> Bool { + guard let lockedUntil = buckets[key]?.lockedUntil else { return false } + return lockedUntil > (await clock.now()) + } + + public func reset(key: MCPRateLimitKey) async { + buckets.removeValue(forKey: key) + } + + private static func describe(_ key: MCPRateLimitKey) -> String { + let address: String + switch key.clientAddress { + case .loopback: + address = "loopback" + case .remote(let value): + address = value + } + return "\(address)/\(key.principalFingerprint ?? "anon")" + } + + private static func seconds(of duration: Duration) -> TimeInterval { + let components = duration.components + return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1.0e18 + } +} diff --git a/TablePro/Core/MCP/Routes/MCPProtocolHandler.swift b/TablePro/Core/MCP/Routes/MCPProtocolHandler.swift index dac72ede6..054b0fed9 100644 --- a/TablePro/Core/MCP/Routes/MCPProtocolHandler.swift +++ b/TablePro/Core/MCP/Routes/MCPProtocolHandler.swift @@ -6,7 +6,7 @@ final class MCPProtocolHandler: MCPRouteHandler, @unchecked Sendable { private weak var server: MCPServer? private let tokenStore: MCPTokenStore? - private let rateLimiter: MCPRateLimiter? + private let rateLimiter: LegacyMCPRateLimiter? private let encoder: JSONEncoder private let decoder: JSONDecoder @@ -14,7 +14,7 @@ final class MCPProtocolHandler: MCPRouteHandler, @unchecked Sendable { var methods: [HTTPRequest.Method] { [.get, .post, .delete] } var path: String { "/mcp" } - init(server: MCPServer, tokenStore: MCPTokenStore?, rateLimiter: MCPRateLimiter?) { + init(server: MCPServer, tokenStore: MCPTokenStore?, rateLimiter: LegacyMCPRateLimiter?) { self.server = server self.tokenStore = tokenStore self.rateLimiter = rateLimiter @@ -136,7 +136,7 @@ final class MCPProtocolHandler: MCPRouteHandler, @unchecked Sendable { } @discardableResult - private func recordAuthFailure(ip: String?) async -> MCPRateLimiter.AuthRateResult? { + private func recordAuthFailure(ip: String?) async -> LegacyMCPRateLimiter.AuthRateResult? { guard let rateLimiter, let ip else { return nil } return await rateLimiter.checkAndRecord(ip: ip, success: false) } @@ -299,7 +299,7 @@ final class MCPProtocolHandler: MCPRouteHandler, @unchecked Sendable { let name = clientInfo["name"]?.stringValue { let version = clientInfo["version"]?.stringValue - await session.setClientInfo(MCPClientInfo(name: name, version: version)) + await session.setClientInfo(LegacyMCPClientInfo(name: name, version: version)) } do { @@ -330,7 +330,7 @@ final class MCPProtocolHandler: MCPRouteHandler, @unchecked Sendable { private func handleCancellation( _ request: JSONRPCRequest, - session: MCPSession + session: LegacyMCPSession ) async -> MCPRouter.RouteResult { guard let params = request.params, let requestIdValue = params["requestId"] diff --git a/TablePro/Core/MCP/Session/MCPClock.swift b/TablePro/Core/MCP/Session/MCPClock.swift new file mode 100644 index 000000000..32b9259dd --- /dev/null +++ b/TablePro/Core/MCP/Session/MCPClock.swift @@ -0,0 +1,18 @@ +import Foundation + +public protocol MCPClock: Sendable { + func now() async -> Date + func sleep(for duration: Duration) async throws +} + +public struct MCPSystemClock: MCPClock { + public init() {} + + public func now() async -> Date { + Date() + } + + public func sleep(for duration: Duration) async throws { + try await Task.sleep(for: duration) + } +} diff --git a/TablePro/Core/MCP/Session/MCPSession.swift b/TablePro/Core/MCP/Session/MCPSession.swift new file mode 100644 index 000000000..8b2f7b187 --- /dev/null +++ b/TablePro/Core/MCP/Session/MCPSession.swift @@ -0,0 +1,99 @@ +import Foundation + +public struct MCPClientInfo: Sendable, Equatable { + public let name: String + public let version: String? + + public init(name: String, version: String? = nil) { + self.name = name + self.version = version + } +} + +public struct MCPSessionSnapshot: Sendable { + public let id: MCPSessionId + public let createdAt: Date + public let lastActivityAt: Date + public let state: MCPSessionState + public let clientInfo: MCPClientInfo? + + public init( + id: MCPSessionId, + createdAt: Date, + lastActivityAt: Date, + state: MCPSessionState, + clientInfo: MCPClientInfo? + ) { + self.id = id + self.createdAt = createdAt + self.lastActivityAt = lastActivityAt + self.state = state + self.clientInfo = clientInfo + } +} + +public enum MCPSessionTransitionError: Error, Sendable, Equatable { + case illegalTransition(from: MCPSessionState, to: MCPSessionState) +} + +public actor MCPSession { + public let id: MCPSessionId + public let createdAt: Date + public private(set) var lastActivityAt: Date + public private(set) var state: MCPSessionState + public private(set) var clientInfo: MCPClientInfo? + public private(set) var negotiatedProtocolVersion: String? + public private(set) var clientCapabilities: JsonValue? + + public init(id: MCPSessionId = .generate(), now: Date = Date()) { + self.id = id + self.createdAt = now + self.lastActivityAt = now + self.state = .initializing + self.clientInfo = nil + self.negotiatedProtocolVersion = nil + self.clientCapabilities = nil + } + + public func touch(now: Date = Date()) { + guard !isTerminated else { return } + lastActivityAt = now + } + + public func recordInitialize( + clientInfo: MCPClientInfo, + protocolVersion: String, + capabilities: JsonValue? + ) { + self.clientInfo = clientInfo + self.negotiatedProtocolVersion = protocolVersion + self.clientCapabilities = capabilities + } + + public func transitionToReady() throws { + guard case .initializing = state else { + throw MCPSessionTransitionError.illegalTransition(from: state, to: .ready) + } + state = .ready + } + + public func terminate(reason: MCPSessionTerminationReason) { + if case .terminated = state { return } + state = .terminated(reason: reason) + } + + public func snapshot() -> MCPSessionSnapshot { + MCPSessionSnapshot( + id: id, + createdAt: createdAt, + lastActivityAt: lastActivityAt, + state: state, + clientInfo: clientInfo + ) + } + + private var isTerminated: Bool { + if case .terminated = state { return true } + return false + } +} diff --git a/TablePro/Core/MCP/Session/MCPSessionEvent.swift b/TablePro/Core/MCP/Session/MCPSessionEvent.swift new file mode 100644 index 000000000..4f219c443 --- /dev/null +++ b/TablePro/Core/MCP/Session/MCPSessionEvent.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum MCPSessionEvent: Sendable { + case created(MCPSessionId) + case terminated(MCPSessionId, reason: MCPSessionTerminationReason) +} diff --git a/TablePro/Core/MCP/Session/MCPSessionId.swift b/TablePro/Core/MCP/Session/MCPSessionId.swift new file mode 100644 index 000000000..4064eb0b0 --- /dev/null +++ b/TablePro/Core/MCP/Session/MCPSessionId.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct MCPSessionId: Sendable, Hashable, Equatable, CustomStringConvertible { + public let rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public static func generate() -> MCPSessionId { + MCPSessionId(UUID().uuidString) + } + + public var description: String { + rawValue + } +} diff --git a/TablePro/Core/MCP/Session/MCPSessionPolicy.swift b/TablePro/Core/MCP/Session/MCPSessionPolicy.swift new file mode 100644 index 000000000..f7c04f666 --- /dev/null +++ b/TablePro/Core/MCP/Session/MCPSessionPolicy.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct MCPSessionPolicy: Sendable, Equatable { + public let idleTimeout: Duration + public let maxSessions: Int + public let cleanupInterval: Duration + + public init(idleTimeout: Duration, maxSessions: Int, cleanupInterval: Duration) { + self.idleTimeout = idleTimeout + self.maxSessions = maxSessions + self.cleanupInterval = cleanupInterval + } + + public static let standard = MCPSessionPolicy( + idleTimeout: .seconds(900), + maxSessions: 16, + cleanupInterval: .seconds(60) + ) +} diff --git a/TablePro/Core/MCP/Session/MCPSessionState.swift b/TablePro/Core/MCP/Session/MCPSessionState.swift new file mode 100644 index 000000000..f55ce60f6 --- /dev/null +++ b/TablePro/Core/MCP/Session/MCPSessionState.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum MCPSessionState: Sendable, Equatable { + case initializing + case ready + case terminated(reason: MCPSessionTerminationReason) +} + +public enum MCPSessionTerminationReason: Sendable, Equatable, CustomStringConvertible { + case clientRequested + case idleTimeout + case capacityEvicted + case serverShutdown + + public var description: String { + switch self { + case .clientRequested: + return "client_requested" + case .idleTimeout: + return "idle_timeout" + case .capacityEvicted: + return "capacity_evicted" + case .serverShutdown: + return "server_shutdown" + } + } +} diff --git a/TablePro/Core/MCP/Session/MCPSessionStore.swift b/TablePro/Core/MCP/Session/MCPSessionStore.swift new file mode 100644 index 000000000..980f954b0 --- /dev/null +++ b/TablePro/Core/MCP/Session/MCPSessionStore.swift @@ -0,0 +1,144 @@ +import Foundation +import os + +public enum MCPSessionStoreError: Error, Sendable, Equatable { + case capacityExceeded(limit: Int) + case sessionNotFound(MCPSessionId) +} + +public actor MCPSessionStore { + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Session") + + private let policy: MCPSessionPolicy + private let clock: any MCPClock + + private var sessions: [MCPSessionId: MCPSession] = [:] + private var eventSubscribers: [UUID: AsyncStream.Continuation] = [:] + private var cleanupTask: Task? + + public init(policy: MCPSessionPolicy = .standard, clock: any MCPClock = MCPSystemClock()) { + self.policy = policy + self.clock = clock + } + + public func create() async throws -> MCPSession { + guard sessions.count < policy.maxSessions else { + Self.logger.warning("Session capacity exceeded (limit \(self.policy.maxSessions))") + throw MCPSessionStoreError.capacityExceeded(limit: policy.maxSessions) + } + + let now = await clock.now() + let session = MCPSession(now: now) + sessions[session.id] = session + Self.logger.info("Session created: \(session.id.rawValue, privacy: .public)") + broadcast(.created(session.id)) + return session + } + + public func session(id: MCPSessionId) async -> MCPSession? { + sessions[id] + } + + public func touch(id: MCPSessionId) async { + guard let session = sessions[id] else { return } + let now = await clock.now() + await session.touch(now: now) + } + + public func terminate(id: MCPSessionId, reason: MCPSessionTerminationReason) async { + guard let session = sessions.removeValue(forKey: id) else { return } + await session.terminate(reason: reason) + Self.logger.info( + "Session terminated: \(id.rawValue, privacy: .public) reason=\(reason.description, privacy: .public)" + ) + broadcast(.terminated(id, reason: reason)) + } + + public func count() async -> Int { + sessions.count + } + + public var events: AsyncStream { + let (stream, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(64) + ) + let subscriberId = UUID() + eventSubscribers[subscriberId] = continuation + continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { await self.removeSubscriber(subscriberId) } + } + return stream + } + + public func startCleanup() async { + guard cleanupTask == nil else { return } + let interval = policy.cleanupInterval + let clockRef = clock + cleanupTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await clockRef.sleep(for: interval) + } catch { + return + } + guard let self else { return } + await self.runCleanupPass() + } + } + } + + public func stopCleanup() async { + cleanupTask?.cancel() + cleanupTask = nil + } + + public func runCleanupPass() async { + let now = await clock.now() + let idleSeconds = Self.seconds(of: policy.idleTimeout) + let cutoff = now.addingTimeInterval(-idleSeconds) + + var expired: [MCPSessionId] = [] + for (sessionId, session) in sessions { + let lastActivity = await session.lastActivityAt + if lastActivity < cutoff { + expired.append(sessionId) + } + } + + for sessionId in expired { + await terminate(id: sessionId, reason: .idleTimeout) + } + + if !expired.isEmpty { + Self.logger.info("Idle cleanup terminated \(expired.count) session(s)") + } + } + + public func shutdown(reason: MCPSessionTerminationReason = .serverShutdown) async { + await stopCleanup() + let activeIds = Array(sessions.keys) + for sessionId in activeIds { + await terminate(id: sessionId, reason: reason) + } + for (_, continuation) in eventSubscribers { + continuation.finish() + } + eventSubscribers.removeAll() + } + + private func broadcast(_ event: MCPSessionEvent) { + for (_, continuation) in eventSubscribers { + continuation.yield(event) + } + } + + private func removeSubscriber(_ id: UUID) { + eventSubscribers.removeValue(forKey: id) + } + + private static func seconds(of duration: Duration) -> TimeInterval { + let components = duration.components + return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1.0e18 + } +} diff --git a/TablePro/Core/MCP/Wire/HttpRequestHead.swift b/TablePro/Core/MCP/Wire/HttpRequestHead.swift new file mode 100644 index 000000000..cd7949f44 --- /dev/null +++ b/TablePro/Core/MCP/Wire/HttpRequestHead.swift @@ -0,0 +1,96 @@ +import Foundation + +public enum HttpMethod: Sendable, Equatable { + case get + case post + case delete + case options + case put + case patch + case head + case other(String) + + public var rawValue: String { + switch self { + case .get: return "GET" + case .post: return "POST" + case .delete: return "DELETE" + case .options: return "OPTIONS" + case .put: return "PUT" + case .patch: return "PATCH" + case .head: return "HEAD" + case .other(let value): return value + } + } + + public init(rawValue: String) { + switch rawValue { + case "GET": self = .get + case "POST": self = .post + case "DELETE": self = .delete + case "OPTIONS": self = .options + case "PUT": self = .put + case "PATCH": self = .patch + case "HEAD": self = .head + default: self = .other(rawValue) + } + } +} + +public struct HttpHeaders: Sendable, Equatable { + private let storage: [(String, String)] + + public init(_ pairs: [(String, String)] = []) { + storage = pairs + } + + public var all: [(String, String)] { + storage + } + + public func value(for name: String) -> String? { + let lowered = name.lowercased() + for (key, value) in storage where key.lowercased() == lowered { + return value + } + return nil + } + + public func values(for name: String) -> [String] { + let lowered = name.lowercased() + return storage.compactMap { key, value in + key.lowercased() == lowered ? value : nil + } + } + + public func contains(_ name: String) -> Bool { + let lowered = name.lowercased() + return storage.contains { key, _ in key.lowercased() == lowered } + } + + public static func == (lhs: HttpHeaders, rhs: HttpHeaders) -> Bool { + guard lhs.storage.count == rhs.storage.count else { return false } + for index in lhs.storage.indices { + let leftPair = lhs.storage[index] + let rightPair = rhs.storage[index] + if leftPair.0 != rightPair.0 || leftPair.1 != rightPair.1 { + return false + } + } + return true + } +} + +public struct HttpRequestHead: Sendable, Equatable { + public let method: HttpMethod + public let path: String + public let httpVersion: String + public let headers: HttpHeaders + + public init(method: HttpMethod, path: String, httpVersion: String, headers: HttpHeaders) { + self.method = method + self.path = path + self.httpVersion = httpVersion + self.headers = headers + } +} diff --git a/TablePro/Core/MCP/Wire/HttpRequestParser.swift b/TablePro/Core/MCP/Wire/HttpRequestParser.swift new file mode 100644 index 000000000..b9faf1c61 --- /dev/null +++ b/TablePro/Core/MCP/Wire/HttpRequestParser.swift @@ -0,0 +1,196 @@ +import Foundation + +public enum HttpRequestParseResult: Sendable, Equatable { + case incomplete + case complete(HttpRequestHead, body: Data, consumedBytes: Int) +} + +public enum HttpRequestParseError: Error, Equatable, Sendable { + case malformedRequestLine + case malformedHeader + case unsupportedHttpVersion(String) + case missingHostHeader + case bodyTooLarge(limit: Int, actual: Int) + case nonStrictLineEndings + case headerTooLarge +} + +public enum HttpRequestParser { + public static let maxHeaderSize = 16 * 1_024 + public static let maxBodySize = 10 * 1_024 * 1_024 + + private static let crlfcrlf: [UInt8] = [0x0D, 0x0A, 0x0D, 0x0A] + private static let lflf: [UInt8] = [0x0A, 0x0A] + + public static func parse(_ buffer: Data) throws -> HttpRequestParseResult { + let bytes = [UInt8](buffer) + + let crlfTerminator = firstIndex(of: crlfcrlf, in: bytes) + let lflfTerminator = firstIndex(of: lflf, in: bytes) + + if let lflfIndex = lflfTerminator { + if let crlfIndex = crlfTerminator { + if lflfIndex < crlfIndex { + throw HttpRequestParseError.nonStrictLineEndings + } + } else { + if lflfIndex <= maxHeaderSize { + throw HttpRequestParseError.nonStrictLineEndings + } + } + } + + guard let headerEndIndex = crlfTerminator else { + if bytes.count > maxHeaderSize { + throw HttpRequestParseError.headerTooLarge + } + return .incomplete + } + + if headerEndIndex > maxHeaderSize { + throw HttpRequestParseError.headerTooLarge + } + + let headerBytes = Array(bytes[0.. maxBodySize { + throw HttpRequestParseError.bodyTooLarge(limit: maxBodySize, actual: contentLength) + } + + let availableBodyBytes = bytes.count - bodyStartIndex + if availableBodyBytes < contentLength { + return .incomplete + } + + let body = Data(bytes[bodyStartIndex..<(bodyStartIndex + contentLength)]) + let consumed = bodyStartIndex + contentLength + return .complete(head, body: body, consumedBytes: consumed) + } + + return .complete(head, body: Data(), consumedBytes: bodyStartIndex) + } + + private static func splitStrictCrlf(_ bytes: [UInt8]) throws -> [[UInt8]] { + var lines: [[UInt8]] = [] + var current: [UInt8] = [] + var index = 0 + while index < bytes.count { + let byte = bytes[index] + if byte == 0x0D { + let nextIndex = index + 1 + if nextIndex >= bytes.count { + throw HttpRequestParseError.malformedHeader + } + if bytes[nextIndex] != 0x0A { + throw HttpRequestParseError.malformedHeader + } + lines.append(current) + current = [] + index = nextIndex + 1 + continue + } + if byte == 0x0A { + throw HttpRequestParseError.nonStrictLineEndings + } + current.append(byte) + index += 1 + } + lines.append(current) + return lines + } + + private static func parseRequestLine(_ bytes: [UInt8]) throws -> (HttpMethod, String, String) { + guard let line = String(bytes: bytes, encoding: .utf8) else { + throw HttpRequestParseError.malformedRequestLine + } + + let parts = line.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: false) + guard parts.count == 3 else { + throw HttpRequestParseError.malformedRequestLine + } + + let methodString = String(parts[0]) + let path = String(parts[1]) + let version = String(parts[2]) + + guard !methodString.isEmpty, !path.isEmpty, !version.isEmpty else { + throw HttpRequestParseError.malformedRequestLine + } + + guard version.hasPrefix("HTTP/") else { + throw HttpRequestParseError.unsupportedHttpVersion(version) + } + + let method = HttpMethod(rawValue: methodString) + return (method, path, version) + } + + private static func parseHeaderLine(_ bytes: [UInt8]) throws -> (String, String) { + guard let line = String(bytes: bytes, encoding: .utf8) else { + throw HttpRequestParseError.malformedHeader + } + + guard let colonIndex = line.firstIndex(of: ":") else { + throw HttpRequestParseError.malformedHeader + } + + let nameSlice = line[line.startIndex.. Int? { + guard !needle.isEmpty, haystack.count >= needle.count else { return nil } + let lastStart = haystack.count - needle.count + var index = 0 + while index <= lastStart { + var matched = true + for offset in 0.. Data { + var output = "HTTP/1.1 \(head.status.code) \(head.status.reasonPhrase)\r\n" + + let hasContentLength = head.headers.contains("Content-Length") + + for (name, value) in head.headers.all { + output += "\(name): \(value)\r\n" + } + + if let body, !hasContentLength { + output += "Content-Length: \(body.count)\r\n" + } + + output += "\r\n" + + var data = Data(output.utf8) + if let body { + data.append(body) + } + return data + } +} diff --git a/TablePro/Core/MCP/Wire/HttpResponseHead.swift b/TablePro/Core/MCP/Wire/HttpResponseHead.swift new file mode 100644 index 000000000..e0e0db861 --- /dev/null +++ b/TablePro/Core/MCP/Wire/HttpResponseHead.swift @@ -0,0 +1,36 @@ +import Foundation + +public struct HttpStatus: Sendable, Equatable { + public let code: Int + public let reasonPhrase: String + + public init(code: Int, reasonPhrase: String) { + self.code = code + self.reasonPhrase = reasonPhrase + } + + public static let ok = HttpStatus(code: 200, reasonPhrase: "OK") + public static let accepted = HttpStatus(code: 202, reasonPhrase: "Accepted") + public static let noContent = HttpStatus(code: 204, reasonPhrase: "No Content") + public static let badRequest = HttpStatus(code: 400, reasonPhrase: "Bad Request") + public static let unauthorized = HttpStatus(code: 401, reasonPhrase: "Unauthorized") + public static let forbidden = HttpStatus(code: 403, reasonPhrase: "Forbidden") + public static let notFound = HttpStatus(code: 404, reasonPhrase: "Not Found") + public static let methodNotAllowed = HttpStatus(code: 405, reasonPhrase: "Method Not Allowed") + public static let notAcceptable = HttpStatus(code: 406, reasonPhrase: "Not Acceptable") + public static let payloadTooLarge = HttpStatus(code: 413, reasonPhrase: "Payload Too Large") + public static let unsupportedMediaType = HttpStatus(code: 415, reasonPhrase: "Unsupported Media Type") + public static let tooManyRequests = HttpStatus(code: 429, reasonPhrase: "Too Many Requests") + public static let internalServerError = HttpStatus(code: 500, reasonPhrase: "Internal Server Error") + public static let serviceUnavailable = HttpStatus(code: 503, reasonPhrase: "Service Unavailable") +} + +public struct HttpResponseHead: Sendable, Equatable { + public let status: HttpStatus + public let headers: HttpHeaders + + public init(status: HttpStatus, headers: HttpHeaders) { + self.status = status + self.headers = headers + } +} diff --git a/TablePro/Core/MCP/Wire/JsonRpcCodec.swift b/TablePro/Core/MCP/Wire/JsonRpcCodec.swift new file mode 100644 index 000000000..39f6d09e1 --- /dev/null +++ b/TablePro/Core/MCP/Wire/JsonRpcCodec.swift @@ -0,0 +1,17 @@ +import Foundation + +public enum JsonRpcCodec { + public static func encode(_ message: JsonRpcMessage) throws -> Data { + try message.encode() + } + + public static func decode(_ data: Data) throws -> JsonRpcMessage { + try JsonRpcMessage.decode(from: data) + } + + public static func encodeLine(_ message: JsonRpcMessage) throws -> Data { + var data = try encode(message) + data.append(0x0A) + return data + } +} diff --git a/TablePro/Core/MCP/Wire/JsonRpcError.swift b/TablePro/Core/MCP/Wire/JsonRpcError.swift new file mode 100644 index 000000000..dede20922 --- /dev/null +++ b/TablePro/Core/MCP/Wire/JsonRpcError.swift @@ -0,0 +1,91 @@ +import Foundation + +public struct JsonRpcError: Codable, Equatable, Sendable { + public let code: Int + public let message: String + public let data: JsonValue? + + public init(code: Int, message: String, data: JsonValue? = nil) { + self.code = code + self.message = message + self.data = data + } + + enum CodingKeys: String, CodingKey { + case code + case message + case data + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decode(Int.self, forKey: .code) + message = try container.decode(String.self, forKey: .message) + data = try container.decodeIfPresent(JsonValue.self, forKey: .data) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(code, forKey: .code) + try container.encode(message, forKey: .message) + try container.encodeIfPresent(data, forKey: .data) + } +} + +public extension JsonRpcError { + static func parseError(message: String = "Parse error", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.parseError, message: message, data: data) + } + + static func invalidRequest(message: String = "Invalid request", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.invalidRequest, message: message, data: data) + } + + static func methodNotFound(message: String = "Method not found", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.methodNotFound, message: message, data: data) + } + + static func invalidParams(message: String = "Invalid params", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.invalidParams, message: message, data: data) + } + + static func internalError(message: String = "Internal error", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.internalError, message: message, data: data) + } + + static func serverError(message: String = "Server error", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.serverError, message: message, data: data) + } + + static func sessionNotFound(message: String = "Session not found", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.sessionNotFound, message: message, data: data) + } + + static func requestCancelled(message: String = "Request cancelled", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.requestCancelled, message: message, data: data) + } + + static func requestTimeout(message: String = "Request timeout", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.requestTimeout, message: message, data: data) + } + + static func resourceNotFound(message: String = "Resource not found", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.resourceNotFound, message: message, data: data) + } + + static func tooLarge(message: String = "Payload too large", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.tooLarge, message: message, data: data) + } + + static func serverDisabled(message: String = "Server disabled", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.serverDisabled, message: message, data: data) + } + + static func forbidden(message: String = "Forbidden", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.forbidden, message: message, data: data) + } + + static func expired(message: String = "Expired", data: JsonValue? = nil) -> Self { + Self(code: JsonRpcErrorCode.expired, message: message, data: data) + } +} diff --git a/TablePro/Core/MCP/Wire/JsonRpcErrorCode.swift b/TablePro/Core/MCP/Wire/JsonRpcErrorCode.swift new file mode 100644 index 000000000..3980935b0 --- /dev/null +++ b/TablePro/Core/MCP/Wire/JsonRpcErrorCode.swift @@ -0,0 +1,21 @@ +import Foundation + +public enum JsonRpcErrorCode { + public static let parseError = -32_700 + public static let invalidRequest = -32_600 + public static let methodNotFound = -32_601 + public static let invalidParams = -32_602 + public static let internalError = -32_603 + + public static let serverError = -32_000 + public static let sessionNotFound = -32_001 + public static let requestCancelled = -32_002 + public static let requestTimeout = -32_003 + public static let resourceNotFound = -32_004 + public static let tooLarge = -32_005 + public static let serverDisabled = -32_006 + public static let forbidden = -32_007 + public static let expired = -32_008 + + public static let serverErrorRange: ClosedRange = -32_099 ... -32_000 +} diff --git a/TablePro/Core/MCP/Wire/JsonRpcId.swift b/TablePro/Core/MCP/Wire/JsonRpcId.swift new file mode 100644 index 000000000..9608ae585 --- /dev/null +++ b/TablePro/Core/MCP/Wire/JsonRpcId.swift @@ -0,0 +1,43 @@ +import Foundation + +public enum JsonRpcId: Codable, Equatable, Hashable, Sendable { + case string(String) + case number(Int64) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + return + } + + if let intValue = try? container.decode(Int64.self) { + self = .number(intValue) + return + } + + if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + return + } + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "JsonRpcId must be a string, integer, or null" + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} diff --git a/TablePro/Core/MCP/Wire/JsonRpcMessage.swift b/TablePro/Core/MCP/Wire/JsonRpcMessage.swift new file mode 100644 index 000000000..b22653d23 --- /dev/null +++ b/TablePro/Core/MCP/Wire/JsonRpcMessage.swift @@ -0,0 +1,269 @@ +import Foundation + +public enum JsonRpcDecodingError: Error, Equatable, Sendable { + case missingJsonRpcVersion + case invalidJsonRpcVersion(String) + case ambiguousMessage + case missingMethod + case missingResultOrError + case batchUnsupported +} + +public struct JsonRpcRequest: Codable, Equatable, Sendable { + public let id: JsonRpcId + public let method: String + public let params: JsonValue? + + public init(id: JsonRpcId, method: String, params: JsonValue? = nil) { + self.id = id + self.method = method + self.params = params + } + + enum CodingKeys: String, CodingKey { + case jsonrpc + case id + case method + case params + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard let version = try container.decodeIfPresent(String.self, forKey: .jsonrpc) else { + throw JsonRpcDecodingError.missingJsonRpcVersion + } + guard version == JsonRpcVersion.current else { + throw JsonRpcDecodingError.invalidJsonRpcVersion(version) + } + id = try container.decode(JsonRpcId.self, forKey: .id) + method = try container.decode(String.self, forKey: .method) + params = try container.decodeIfPresent(JsonValue.self, forKey: .params) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(JsonRpcVersion.current, forKey: .jsonrpc) + try container.encode(id, forKey: .id) + try container.encode(method, forKey: .method) + try container.encodeIfPresent(params, forKey: .params) + } +} + +public struct JsonRpcNotification: Codable, Equatable, Sendable { + public let method: String + public let params: JsonValue? + + public init(method: String, params: JsonValue? = nil) { + self.method = method + self.params = params + } + + enum CodingKeys: String, CodingKey { + case jsonrpc + case method + case params + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard let version = try container.decodeIfPresent(String.self, forKey: .jsonrpc) else { + throw JsonRpcDecodingError.missingJsonRpcVersion + } + guard version == JsonRpcVersion.current else { + throw JsonRpcDecodingError.invalidJsonRpcVersion(version) + } + method = try container.decode(String.self, forKey: .method) + params = try container.decodeIfPresent(JsonValue.self, forKey: .params) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(JsonRpcVersion.current, forKey: .jsonrpc) + try container.encode(method, forKey: .method) + try container.encodeIfPresent(params, forKey: .params) + } +} + +public struct JsonRpcSuccessResponse: Codable, Equatable, Sendable { + public let id: JsonRpcId + public let result: JsonValue + + public init(id: JsonRpcId, result: JsonValue) { + self.id = id + self.result = result + } + + enum CodingKeys: String, CodingKey { + case jsonrpc + case id + case result + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard let version = try container.decodeIfPresent(String.self, forKey: .jsonrpc) else { + throw JsonRpcDecodingError.missingJsonRpcVersion + } + guard version == JsonRpcVersion.current else { + throw JsonRpcDecodingError.invalidJsonRpcVersion(version) + } + id = try container.decode(JsonRpcId.self, forKey: .id) + result = try container.decode(JsonValue.self, forKey: .result) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(JsonRpcVersion.current, forKey: .jsonrpc) + try container.encode(id, forKey: .id) + try container.encode(result, forKey: .result) + } +} + +public struct JsonRpcErrorResponse: Codable, Equatable, Sendable { + public let id: JsonRpcId? + public let error: JsonRpcError + + public init(id: JsonRpcId?, error: JsonRpcError) { + self.id = id + self.error = error + } + + enum CodingKeys: String, CodingKey { + case jsonrpc + case id + case error + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard let version = try container.decodeIfPresent(String.self, forKey: .jsonrpc) else { + throw JsonRpcDecodingError.missingJsonRpcVersion + } + guard version == JsonRpcVersion.current else { + throw JsonRpcDecodingError.invalidJsonRpcVersion(version) + } + if container.contains(.id) { + id = try container.decode(JsonRpcId.self, forKey: .id) + } else { + id = nil + } + error = try container.decode(JsonRpcError.self, forKey: .error) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(JsonRpcVersion.current, forKey: .jsonrpc) + if let id { + try container.encode(id, forKey: .id) + } else { + try container.encode(JsonRpcId.null, forKey: .id) + } + try container.encode(error, forKey: .error) + } +} + +public enum JsonRpcMessage: Equatable, Sendable { + case request(JsonRpcRequest) + case notification(JsonRpcNotification) + case successResponse(JsonRpcSuccessResponse) + case errorResponse(JsonRpcErrorResponse) +} + +extension JsonRpcMessage: Codable { + enum DiscriminatorKeys: String, CodingKey { + case jsonrpc + case id + case method + case params + case result + case error + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminatorKeys.self) + + guard let version = try container.decodeIfPresent(String.self, forKey: .jsonrpc) else { + throw JsonRpcDecodingError.missingJsonRpcVersion + } + guard version == JsonRpcVersion.current else { + throw JsonRpcDecodingError.invalidJsonRpcVersion(version) + } + + let hasId = container.contains(.id) + let hasMethod = container.contains(.method) + let hasResult = container.contains(.result) + let hasError = container.contains(.error) + + if hasMethod, hasResult || hasError { + throw JsonRpcDecodingError.ambiguousMessage + } + + if hasResult, hasError { + throw JsonRpcDecodingError.ambiguousMessage + } + + if hasMethod { + if hasId { + self = .request(try JsonRpcRequest(from: decoder)) + return + } + self = .notification(try JsonRpcNotification(from: decoder)) + return + } + + if hasResult { + self = .successResponse(try JsonRpcSuccessResponse(from: decoder)) + return + } + + if hasError { + self = .errorResponse(try JsonRpcErrorResponse(from: decoder)) + return + } + + if hasId { + throw JsonRpcDecodingError.missingResultOrError + } + + throw JsonRpcDecodingError.missingMethod + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .request(let request): + try request.encode(to: encoder) + case .notification(let notification): + try notification.encode(to: encoder) + case .successResponse(let response): + try response.encode(to: encoder) + case .errorResponse(let response): + try response.encode(to: encoder) + } + } +} + +public extension JsonRpcMessage { + static func decode(from data: Data) throws -> JsonRpcMessage { + guard let firstNonWhitespace = data.first(where: { !$0.isAsciiWhitespace }) else { + throw JsonRpcDecodingError.missingJsonRpcVersion + } + if firstNonWhitespace == 0x5B { + throw JsonRpcDecodingError.batchUnsupported + } + + let decoder = JSONDecoder() + return try decoder.decode(JsonRpcMessage.self, from: data) + } + + func encode() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [] + return try encoder.encode(self) + } +} + +private extension UInt8 { + var isAsciiWhitespace: Bool { + self == 0x20 || self == 0x09 || self == 0x0A || self == 0x0D + } +} diff --git a/TablePro/Core/MCP/Wire/JsonRpcVersion.swift b/TablePro/Core/MCP/Wire/JsonRpcVersion.swift new file mode 100644 index 000000000..ed52ccd3a --- /dev/null +++ b/TablePro/Core/MCP/Wire/JsonRpcVersion.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum JsonRpcVersionError: Error, Equatable, Sendable { + case unsupported(String) +} + +public enum JsonRpcVersion { + public static let current = "2.0" + + public static func validate(_ value: String) throws { + guard value == current else { + throw JsonRpcVersionError.unsupported(value) + } + } +} diff --git a/TablePro/Core/MCP/Wire/JsonValue.swift b/TablePro/Core/MCP/Wire/JsonValue.swift new file mode 100644 index 000000000..9ff2d6502 --- /dev/null +++ b/TablePro/Core/MCP/Wire/JsonValue.swift @@ -0,0 +1,162 @@ +import Foundation + +public enum JsonValue: Codable, Equatable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JsonValue]) + case object([String: JsonValue]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + return + } + + if let boolValue = try? container.decode(Bool.self) { + self = .bool(boolValue) + return + } + + if let intValue = try? container.decode(Int.self) { + self = .int(intValue) + return + } + + if let doubleValue = try? container.decode(Double.self) { + self = .double(doubleValue) + return + } + + if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + return + } + + if let arrayValue = try? container.decode([JsonValue].self) { + self = .array(arrayValue) + return + } + + if let objectValue = try? container.decode([String: JsonValue].self) { + self = .object(objectValue) + return + } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode JsonValue") + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .bool(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } +} + +extension JsonValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JsonValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .int(value) + } +} + +extension JsonValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .double(value) + } +} + +extension JsonValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension JsonValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +extension JsonValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JsonValue...) { + self = .array(elements) + } +} + +extension JsonValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JsonValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } +} + +public extension JsonValue { + subscript(key: String) -> JsonValue? { + guard case .object(let dict) = self else { return nil } + return dict[key] + } + + var isNull: Bool { + if case .null = self { return true } + return false + } + + var stringValue: String? { + guard case .string(let value) = self else { return nil } + return value + } + + var intValue: Int? { + guard case .int(let value) = self else { return nil } + return value + } + + var boolValue: Bool? { + guard case .bool(let value) = self else { return nil } + return value + } + + var doubleValue: Double? { + switch self { + case .double(let value): + return value + case .int(let value): + return Double(value) + default: + return nil + } + } + + var arrayValue: [JsonValue]? { + guard case .array(let value) = self else { return nil } + return value + } + + var objectValue: [String: JsonValue]? { + guard case .object(let value) = self else { return nil } + return value + } +} diff --git a/TablePro/Core/MCP/Wire/SseDecoder.swift b/TablePro/Core/MCP/Wire/SseDecoder.swift new file mode 100644 index 000000000..d8a9d61f8 --- /dev/null +++ b/TablePro/Core/MCP/Wire/SseDecoder.swift @@ -0,0 +1,129 @@ +import Foundation + +public actor SseDecoder { + private var buffer: Data + private var pendingEvent: String? + private var pendingId: String? + private var pendingRetry: Int? + private var pendingDataLines: [String] + private var hasPendingFields: Bool + + public init() { + buffer = Data() + pendingEvent = nil + pendingId = nil + pendingRetry = nil + pendingDataLines = [] + hasPendingFields = false + } + + public func feed(_ chunk: Data) -> [SseFrame] { + buffer.append(chunk) + + var frames: [SseFrame] = [] + + while let line = takeLine() { + if line.isEmpty { + if let frame = flushFrame() { + frames.append(frame) + } + continue + } + processLine(line) + } + + return frames + } + + private func takeLine() -> String? { + var index = buffer.startIndex + while index < buffer.endIndex { + let byte = buffer[index] + if byte == 0x0A { + let lineData = buffer[buffer.startIndex.. String { + String(data: data, encoding: .utf8) ?? "" + } + + private func processLine(_ line: String) { + if line.first == ":" { + return + } + + let field: String + let value: String + if let colonIndex = line.firstIndex(of: ":") { + field = String(line[line.startIndex.. SseFrame? { + defer { resetPending() } + + guard hasPendingFields else { return nil } + guard !pendingDataLines.isEmpty else { return nil } + + let data = pendingDataLines.joined(separator: "\n") + return SseFrame( + event: pendingEvent, + id: pendingId, + data: data, + retry: pendingRetry + ) + } + + private func resetPending() { + pendingEvent = nil + pendingId = nil + pendingRetry = nil + pendingDataLines = [] + hasPendingFields = false + } +} diff --git a/TablePro/Core/MCP/Wire/SseEncoder.swift b/TablePro/Core/MCP/Wire/SseEncoder.swift new file mode 100644 index 000000000..aa75adf9f --- /dev/null +++ b/TablePro/Core/MCP/Wire/SseEncoder.swift @@ -0,0 +1,58 @@ +import Foundation + +public enum SseEncoder { + public static func encode(_ frame: SseFrame) -> Data { + var output = "" + + if let event = frame.event { + output += "event: \(event)\n" + } + + if let id = frame.id { + output += "id: \(id)\n" + } + + if let retry = frame.retry { + output += "retry: \(retry)\n" + } + + let dataLines = splitLines(frame.data) + for line in dataLines { + output += "data: \(line)\n" + } + + output += "\n" + return Data(output.utf8) + } + + private static func splitLines(_ value: String) -> [String] { + var lines: [String] = [] + var current = "" + let characters = Array(value) + var index = 0 + while index < characters.count { + let char = characters[index] + if char == "\r" { + lines.append(current) + current = "" + let nextIndex = index + 1 + if nextIndex < characters.count, characters[nextIndex] == "\n" { + index = nextIndex + 1 + continue + } + index += 1 + continue + } + if char == "\n" { + lines.append(current) + current = "" + index += 1 + continue + } + current.append(char) + index += 1 + } + lines.append(current) + return lines + } +} diff --git a/TablePro/Core/MCP/Wire/SseFrame.swift b/TablePro/Core/MCP/Wire/SseFrame.swift new file mode 100644 index 000000000..eea89d726 --- /dev/null +++ b/TablePro/Core/MCP/Wire/SseFrame.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct SseFrame: Sendable, Equatable { + public let event: String? + public let id: String? + public let data: String + public let retry: Int? + + public init(event: String? = nil, id: String? = nil, data: String, retry: Int? = nil) { + self.event = event + self.id = id + self.data = data + self.retry = retry + } +} diff --git a/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift b/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift new file mode 100644 index 000000000..c37da24c8 --- /dev/null +++ b/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift @@ -0,0 +1,228 @@ +import Foundation +@testable import TablePro +import Testing + +actor FakeMCPTokenStore: MCPTokenStoreProtocol { + private var tokens: [String: MCPValidatedToken] = [:] + private var expired: Set = [] + private var revoked: Set = [] + + func register(_ plaintext: String, validated: MCPValidatedToken) { + tokens[plaintext] = validated + } + + func markExpired(_ plaintext: String) { + expired.insert(plaintext) + } + + func markRevoked(_ plaintext: String) { + revoked.insert(plaintext) + } + + func validateBearerToken(_ token: String) async -> Result { + if expired.contains(token) { + return .failure(.expired) + } + if revoked.contains(token) { + return .failure(.revoked) + } + if let validated = tokens[token] { + return .success(validated) + } + return .failure(.unknownToken) + } +} + +@Suite("MCP Bearer Token Authenticator") +struct MCPBearerTokenAuthenticatorTests { + private func makePrincipal(label: String = "test", scopes: Set = [.toolsRead]) -> MCPValidatedToken { + MCPValidatedToken( + label: label, + scopes: scopes, + issuedAt: Date(timeIntervalSince1970: 1_000_000), + expiresAt: nil + ) + } + + private func makeAuthenticator( + store: FakeMCPTokenStore, + clock: MCPTestClock = MCPTestClock() + ) -> (MCPBearerTokenAuthenticator, MCPRateLimiter) { + let limiter = MCPRateLimiter(clock: clock) + let authenticator = MCPBearerTokenAuthenticator(tokenStore: store, rateLimiter: limiter) + return (authenticator, limiter) + } + + @Test("Missing header returns 401 with bearer challenge") + func missingHeader() async { + let store = FakeMCPTokenStore() + let (authenticator, _) = makeAuthenticator(store: store) + let decision = await authenticator.authenticate( + authorizationHeader: nil, + clientAddress: .loopback + ) + guard case .deny(let reason) = decision else { + Issue.record("Expected deny, got \(decision)") + return + } + #expect(reason.httpStatus == 401) + #expect(reason.challenge?.contains("Bearer") == true) + } + + @Test("Empty header returns 401") + func emptyHeader() async { + let store = FakeMCPTokenStore() + let (authenticator, _) = makeAuthenticator(store: store) + let decision = await authenticator.authenticate( + authorizationHeader: "", + clientAddress: .loopback + ) + guard case .deny(let reason) = decision else { + Issue.record("Expected deny") + return + } + #expect(reason.httpStatus == 401) + } + + @Test("Bad scheme returns 401") + func badScheme() async { + let store = FakeMCPTokenStore() + let (authenticator, _) = makeAuthenticator(store: store) + let decision = await authenticator.authenticate( + authorizationHeader: "Basic abc123", + clientAddress: .loopback + ) + guard case .deny(let reason) = decision else { + Issue.record("Expected deny") + return + } + #expect(reason.httpStatus == 401) + } + + @Test("Valid token returns allow with principal") + func validToken() async { + let store = FakeMCPTokenStore() + let plaintext = "tp_validtoken123" + await store.register(plaintext, validated: makePrincipal()) + let (authenticator, _) = makeAuthenticator(store: store) + let decision = await authenticator.authenticate( + authorizationHeader: "Bearer \(plaintext)", + clientAddress: .loopback + ) + guard case .allow(let principal) = decision else { + Issue.record("Expected allow, got \(decision)") + return + } + #expect(principal.scopes.contains(.toolsRead)) + #expect(principal.tokenFingerprint.count == 8) + #expect(!principal.tokenFingerprint.contains(plaintext)) + } + + @Test("Bearer scheme is case-insensitive") + func bearerCaseInsensitive() async { + let store = FakeMCPTokenStore() + let plaintext = "tp_token" + await store.register(plaintext, validated: makePrincipal()) + let (authenticator, _) = makeAuthenticator(store: store) + let decision = await authenticator.authenticate( + authorizationHeader: "bEaReR \(plaintext)", + clientAddress: .loopback + ) + guard case .allow = decision else { + Issue.record("Expected allow") + return + } + } + + @Test("Expired token returns 401 expired") + func expiredToken() async { + let store = FakeMCPTokenStore() + let plaintext = "tp_expired" + await store.register(plaintext, validated: makePrincipal()) + await store.markExpired(plaintext) + let (authenticator, _) = makeAuthenticator(store: store) + let decision = await authenticator.authenticate( + authorizationHeader: "Bearer \(plaintext)", + clientAddress: .loopback + ) + guard case .deny(let reason) = decision else { + Issue.record("Expected deny") + return + } + #expect(reason.httpStatus == 401) + #expect(reason.logMessage == "token_expired") + } + + @Test("Repeated bad token leads to rate limited 429") + func repeatedBadTokenRateLimited() async { + let store = FakeMCPTokenStore() + let clock = MCPTestClock() + let limiter = MCPRateLimiter(clock: clock) + let authenticator = MCPBearerTokenAuthenticator(tokenStore: store, rateLimiter: limiter) + + let badToken = "tp_unknown" + for _ in 0..<5 { + _ = await authenticator.authenticate( + authorizationHeader: "Bearer \(badToken)", + clientAddress: .loopback + ) + } + let final = await authenticator.authenticate( + authorizationHeader: "Bearer \(badToken)", + clientAddress: .loopback + ) + guard case .deny(let reason) = final else { + Issue.record("Expected deny") + return + } + #expect(reason.httpStatus == 429) + } + + @Test("Successful auth resets rate limit bucket") + func successResetsRateLimit() async { + let store = FakeMCPTokenStore() + let plaintext = "tp_good" + await store.register(plaintext, validated: makePrincipal()) + let clock = MCPTestClock() + let limiter = MCPRateLimiter(clock: clock) + let authenticator = MCPBearerTokenAuthenticator(tokenStore: store, rateLimiter: limiter) + + let goodHeader = "Bearer \(plaintext)" + for _ in 0..<3 { + _ = await authenticator.authenticate( + authorizationHeader: goodHeader, + clientAddress: .loopback + ) + } + let fingerprint = MCPBearerTokenAuthenticator.fingerprint(of: plaintext) + let key = MCPRateLimitKey(clientAddress: .loopback, principalFingerprint: fingerprint) + let locked = await limiter.isLocked(key: key) + #expect(locked == false) + } + + @Test("Different addresses with same token are isolated by rate limiter") + func addressIsolation() async { + let store = FakeMCPTokenStore() + let plaintext = "tp_token" + await store.register(plaintext, validated: makePrincipal()) + let clock = MCPTestClock() + let limiter = MCPRateLimiter(clock: clock) + let authenticator = MCPBearerTokenAuthenticator(tokenStore: store, rateLimiter: limiter) + + for _ in 0..<5 { + _ = await authenticator.authenticate( + authorizationHeader: "Bearer wrong", + clientAddress: .loopback + ) + } + + let decision = await authenticator.authenticate( + authorizationHeader: "Bearer \(plaintext)", + clientAddress: .remote("10.0.0.1") + ) + guard case .allow = decision else { + Issue.record("Expected allow on different address, got \(decision)") + return + } + } +} diff --git a/TableProTests/Core/MCP/Helpers/MCPTestClock.swift b/TableProTests/Core/MCP/Helpers/MCPTestClock.swift new file mode 100644 index 000000000..5fb8342a4 --- /dev/null +++ b/TableProTests/Core/MCP/Helpers/MCPTestClock.swift @@ -0,0 +1,62 @@ +import Foundation +@testable import TablePro + +public actor MCPTestClock: MCPClock { + private var currentDate: Date + private var pendingSleeps: [PendingSleep] = [] + + private struct PendingSleep { + let dueAt: Date + let continuation: CheckedContinuation + } + + public init(start: Date = Date(timeIntervalSince1970: 1_700_000_000)) { + self.currentDate = start + } + + public func now() -> Date { + currentDate + } + + public func sleep(for duration: Duration) async throws { + let dueAt = currentDate.addingTimeInterval(Self.seconds(of: duration)) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + pendingSleeps.append(PendingSleep(dueAt: dueAt, continuation: continuation)) + } + } + + public func advance(by duration: Duration) async { + let target = currentDate.addingTimeInterval(Self.seconds(of: duration)) + currentDate = target + + let due = pendingSleeps.filter { $0.dueAt <= target } + pendingSleeps.removeAll { $0.dueAt <= target } + for sleep in due { + sleep.continuation.resume() + } + + await Task.yield() + } + + public func setNow(_ date: Date) async { + currentDate = date + let due = pendingSleeps.filter { $0.dueAt <= date } + pendingSleeps.removeAll { $0.dueAt <= date } + for sleep in due { + sleep.continuation.resume() + } + } + + public func cancelAllSleeps() { + let cancelled = pendingSleeps + pendingSleeps.removeAll() + for sleep in cancelled { + sleep.continuation.resume(throwing: CancellationError()) + } + } + + private static func seconds(of duration: Duration) -> TimeInterval { + let components = duration.components + return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1.0e18 + } +} diff --git a/TableProTests/Core/MCP/LegacyMCPRateLimiterTests.swift b/TableProTests/Core/MCP/LegacyMCPRateLimiterTests.swift new file mode 100644 index 000000000..9d3916f3f --- /dev/null +++ b/TableProTests/Core/MCP/LegacyMCPRateLimiterTests.swift @@ -0,0 +1,216 @@ +// +// MCPRateLimiterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("MCP Rate Limiter") +struct MCPRateLimiterTests { + private func makeLimiter() -> LegacyMCPRateLimiter { + LegacyMCPRateLimiter() + } + + private func expectAllowed(_ result: LegacyMCPRateLimiter.AuthRateResult, message: String = "") { + guard case .allowed = result else { + Issue.record("Expected .allowed but got \(result). \(message)") + return + } + } + + @discardableResult + private func expectRateLimited(_ result: LegacyMCPRateLimiter.AuthRateResult, message: String = "") -> Duration? { + guard case .rateLimited(let retryAfter) = result else { + Issue.record("Expected .rateLimited but got \(result). \(message)") + return nil + } + return retryAfter + } + + @Test("First request is allowed") + func firstRequestAllowed() async { + let limiter = makeLimiter() + let result = await limiter.checkAndRecord(ip: "1.2.3.4", success: false) + expectAllowed(result) + } + + @Test("Success clears failure record") + func successClearsFailureRecord() async { + let limiter = makeLimiter() + _ = await limiter.checkAndRecord(ip: "1.2.3.4", success: false) + _ = await limiter.checkAndRecord(ip: "1.2.3.4", success: true) + let result = await limiter.checkAndRecord(ip: "1.2.3.4", success: false) + expectAllowed(result, message: "Counter should have been reset by success") + } + + @Test("Unknown IP is allowed") + func unknownIpAllowed() async { + let limiter = makeLimiter() + let result = await limiter.checkAndRecord(ip: "never-seen-before", success: false) + expectAllowed(result) + } + + @Test("isLockedOut for unknown IP returns allowed") + func isLockedOutUnknownIp() async { + let limiter = makeLimiter() + let result = await limiter.isLockedOut(ip: "unknown") + expectAllowed(result) + } + + @Test("Second failure triggers 1s lockout") + func secondFailureLockout() async { + let limiter = makeLimiter() + _ = await limiter.checkAndRecord(ip: "10.0.0.1", success: false) + let result = await limiter.checkAndRecord(ip: "10.0.0.1", success: false) + + guard let retryAfter = expectRateLimited(result, message: "Second failure should lock out") else { return } + let seconds = retryAfter.components.seconds + #expect(seconds >= 0 && seconds <= 2) + } + + @Test("Third failure triggers 5s lockout after previous lockout expires") + func thirdFailureLockout() async { + let limiter = makeLimiter() + _ = await limiter.checkAndRecord(ip: "10.0.0.2", success: false) + _ = await limiter.checkAndRecord(ip: "10.0.0.2", success: false) + + try? await Task.sleep(for: .seconds(1.1)) + + let result = await limiter.checkAndRecord(ip: "10.0.0.2", success: false) + guard let retryAfter = expectRateLimited(result, message: "Third failure should lock out for ~5s") else { return } + let seconds = retryAfter.components.seconds + #expect(seconds >= 4 && seconds <= 6) + } + + @Test("Fourth failure triggers 30s lockout") + func fourthFailureLockout() async { + let limiter = makeLimiter() + _ = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) + _ = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) + + try? await Task.sleep(for: .seconds(1.1)) + _ = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) + + try? await Task.sleep(for: .seconds(5.1)) + let result = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) + + guard let retryAfter = expectRateLimited(result, message: "Fourth failure should lock out for ~30s") else { return } + let seconds = retryAfter.components.seconds + #expect(seconds >= 28 && seconds <= 32) + } + + @Test("Repeated failures while locked return remaining lockout time") + func repeatedFailuresWhileLocked() async { + let limiter = makeLimiter() + _ = await limiter.checkAndRecord(ip: "10.0.0.4", success: false) + let lockResult = await limiter.checkAndRecord(ip: "10.0.0.4", success: false) + + guard let initialRetry = expectRateLimited(lockResult) else { return } + + let retryResult = await limiter.checkAndRecord(ip: "10.0.0.4", success: false) + guard let remainingRetry = expectRateLimited(retryResult, message: "Should still be locked") else { return } + + #expect(remainingRetry <= initialRetry) + } + + @Test("isLockedOut returns rateLimited during lockout") + func isLockedOutDuringLockout() async { + let limiter = makeLimiter() + _ = await limiter.checkAndRecord(ip: "10.0.1.1", success: false) + _ = await limiter.checkAndRecord(ip: "10.0.1.1", success: false) + + let result = await limiter.isLockedOut(ip: "10.0.1.1") + expectRateLimited(result, message: "Should be locked out after 2 failures") + } + + @Test("isLockedOut returns allowed when not locked") + func isLockedOutWhenNotLocked() async { + let limiter = makeLimiter() + let result = await limiter.isLockedOut(ip: "fresh-ip") + expectAllowed(result) + } + + @Test("Different IPs have independent counters") + func independentCounters() async { + let limiter = makeLimiter() + _ = await limiter.checkAndRecord(ip: "ip-a", success: false) + _ = await limiter.checkAndRecord(ip: "ip-a", success: false) + + let lockedResult = await limiter.isLockedOut(ip: "ip-a") + expectRateLimited(lockedResult, message: "IP-A should be locked") + + let resultB = await limiter.checkAndRecord(ip: "ip-b", success: false) + expectAllowed(resultB, message: "IP-B should be independent of IP-A") + } + + @Test("Locking one IP does not affect another") + func lockingIsolation() async { + let limiter = makeLimiter() + _ = await limiter.checkAndRecord(ip: "ip-a", success: false) + _ = await limiter.checkAndRecord(ip: "ip-a", success: false) + + let lockedResult = await limiter.isLockedOut(ip: "ip-a") + expectRateLimited(lockedResult, message: "IP-A should be locked") + + let resultB = await limiter.checkAndRecord(ip: "ip-b", success: false) + expectAllowed(resultB, message: "IP-B should not be affected by IP-A lockout") + } + + @Test("Success after failure resets counter") + func successResetsCounter() async { + let limiter = makeLimiter() + _ = await limiter.checkAndRecord(ip: "10.0.2.1", success: false) + _ = await limiter.checkAndRecord(ip: "10.0.2.1", success: true) + + let firstFail = await limiter.checkAndRecord(ip: "10.0.2.1", success: false) + expectAllowed(firstFail, message: "Counter should reset after success, so first failure again is allowed") + + let secondFail = await limiter.checkAndRecord(ip: "10.0.2.1", success: false) + expectRateLimited(secondFail, message: "Second failure after reset should lock out again") + } + + @Test("Empty IP string works") + func emptyIpString() async { + let limiter = makeLimiter() + let result = await limiter.checkAndRecord(ip: "", success: false) + expectAllowed(result, message: "First failure for empty IP should be allowed") + } + + @Test("Success on first call returns allowed without prior record") + func successOnFirstCall() async { + let limiter = makeLimiter() + let result = await limiter.checkAndRecord(ip: "10.0.3.1", success: true) + expectAllowed(result) + } + + @Test("Rapid sequential failures while locked do not escalate") + func rapidSequentialFailuresWhileLocked() async { + let limiter = makeLimiter() + let ip = "10.0.3.2" + + let result1 = await limiter.checkAndRecord(ip: ip, success: false) + expectAllowed(result1, message: "Failure 1 should be allowed") + + let result2 = await limiter.checkAndRecord(ip: ip, success: false) + guard let retry2 = expectRateLimited(result2, message: "Failure 2 should trigger lockout") else { return } + #expect(retry2.components.seconds >= 0 && retry2.components.seconds <= 2) + + let result3 = await limiter.checkAndRecord(ip: ip, success: false) + guard let retry3 = expectRateLimited(result3, message: "Failure 3 while locked returns remaining time") else { return } + #expect(retry3 <= retry2) + + let result4 = await limiter.checkAndRecord(ip: ip, success: false) + guard let retry4 = expectRateLimited(result4, message: "Failure 4 while locked returns remaining time") else { return } + #expect(retry4 <= retry3) + } + + @Test("isLockedOut returns allowed after single failure with no lockout") + func isLockedOutAfterSingleFailure() async { + let limiter = makeLimiter() + _ = await limiter.checkAndRecord(ip: "10.0.4.1", success: false) + let result = await limiter.isLockedOut(ip: "10.0.4.1") + expectAllowed(result, message: "Single failure sets no lockout") + } +} diff --git a/TableProTests/Core/MCP/RateLimit/MCPRateLimiterTests.swift b/TableProTests/Core/MCP/RateLimit/MCPRateLimiterTests.swift new file mode 100644 index 000000000..3f8a3beb8 --- /dev/null +++ b/TableProTests/Core/MCP/RateLimit/MCPRateLimiterTests.swift @@ -0,0 +1,143 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("MCP Rate Limiter") +struct MCPRateLimiterNewTests { + private func standardKey() -> MCPRateLimitKey { + MCPRateLimitKey(clientAddress: .loopback, principalFingerprint: "abcd1234") + } + + @Test("Five failures lock the key") + func fiveFailuresLock() async { + let clock = MCPTestClock() + let limiter = MCPRateLimiter(clock: clock) + let key = standardKey() + + for _ in 0..<4 { + let verdict = await limiter.recordAttempt(key: key, success: false) + #expect(verdict == .allowed) + } + let final = await limiter.recordAttempt(key: key, success: false) + guard case .lockedUntil = final else { + Issue.record("Expected lockedUntil, got \(final)") + return + } + let locked = await limiter.isLocked(key: key) + #expect(locked == true) + } + + @Test("Lock expires after lockout duration") + func lockExpires() async { + let clock = MCPTestClock() + let limiter = MCPRateLimiter( + policy: MCPRateLimitPolicy( + maxFailedAttempts: 3, + windowDuration: .seconds(60), + lockoutDuration: .seconds(120) + ), + clock: clock + ) + let key = standardKey() + + for _ in 0..<3 { + _ = await limiter.recordAttempt(key: key, success: false) + } + let lockedNow = await limiter.isLocked(key: key) + #expect(lockedNow == true) + + await clock.advance(by: .seconds(121)) + let lockedLater = await limiter.isLocked(key: key) + #expect(lockedLater == false) + } + + @Test("Different keys are isolated") + func differentKeysIsolated() async { + let clock = MCPTestClock() + let limiter = MCPRateLimiter(clock: clock) + let keyA = MCPRateLimitKey(clientAddress: .loopback, principalFingerprint: "tokenA") + let keyB = MCPRateLimitKey(clientAddress: .loopback, principalFingerprint: "tokenB") + + for _ in 0..<5 { + _ = await limiter.recordAttempt(key: keyA, success: false) + } + let lockedA = await limiter.isLocked(key: keyA) + let lockedB = await limiter.isLocked(key: keyB) + #expect(lockedA == true) + #expect(lockedB == false) + } + + @Test("Same address different principal does not share bucket") + func sameAddressDifferentPrincipal() async { + let clock = MCPTestClock() + let limiter = MCPRateLimiter(clock: clock) + let attacker = MCPRateLimitKey(clientAddress: .loopback, principalFingerprint: "bad") + let legitimate = MCPRateLimitKey(clientAddress: .loopback, principalFingerprint: "good") + + for _ in 0..<5 { + _ = await limiter.recordAttempt(key: attacker, success: false) + } + let allowed = await limiter.recordAttempt(key: legitimate, success: true) + #expect(allowed == .allowed) + } + + @Test("Success resets failure count") + func successResetsFailureCount() async { + let clock = MCPTestClock() + let limiter = MCPRateLimiter( + policy: MCPRateLimitPolicy( + maxFailedAttempts: 5, + windowDuration: .seconds(60), + lockoutDuration: .seconds(300) + ), + clock: clock + ) + let key = standardKey() + + for _ in 0..<3 { + _ = await limiter.recordAttempt(key: key, success: false) + } + _ = await limiter.recordAttempt(key: key, success: true) + + for _ in 0..<4 { + let verdict = await limiter.recordAttempt(key: key, success: false) + #expect(verdict == .allowed) + } + let locked = await limiter.isLocked(key: key) + #expect(locked == false) + } + + @Test("Failures outside window do not count") + func failuresOutsideWindowExpire() async { + let clock = MCPTestClock() + let limiter = MCPRateLimiter( + policy: MCPRateLimitPolicy( + maxFailedAttempts: 5, + windowDuration: .seconds(60), + lockoutDuration: .seconds(300) + ), + clock: clock + ) + let key = standardKey() + + for _ in 0..<4 { + _ = await limiter.recordAttempt(key: key, success: false) + } + await clock.advance(by: .seconds(120)) + let verdict = await limiter.recordAttempt(key: key, success: false) + #expect(verdict == .allowed) + } + + @Test("Reset clears the bucket") + func resetClearsBucket() async { + let clock = MCPTestClock() + let limiter = MCPRateLimiter(clock: clock) + let key = standardKey() + for _ in 0..<5 { + _ = await limiter.recordAttempt(key: key, success: false) + } + await limiter.reset(key: key) + let locked = await limiter.isLocked(key: key) + #expect(locked == false) + } +} diff --git a/TableProTests/Core/MCP/Session/MCPSessionStoreTests.swift b/TableProTests/Core/MCP/Session/MCPSessionStoreTests.swift new file mode 100644 index 000000000..f351c2556 --- /dev/null +++ b/TableProTests/Core/MCP/Session/MCPSessionStoreTests.swift @@ -0,0 +1,182 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("MCP Session Store") +struct MCPSessionStoreTests { + @Test("Create then lookup returns same session") + func createThenLookup() async throws { + let store = MCPSessionStore() + let session = try await store.create() + let found = await store.session(id: session.id) + #expect(found != nil) + let count = await store.count() + #expect(count == 1) + } + + @Test("Touch updates session lastActivity to current clock time") + func touchUpdatesLastActivity() async throws { + let clock = MCPTestClock(start: Date(timeIntervalSince1970: 1_000_000)) + let store = MCPSessionStore(clock: clock) + let session = try await store.create() + + await clock.advance(by: .seconds(120)) + await store.touch(id: session.id) + + let activity = await session.lastActivityAt + let expected = Date(timeIntervalSince1970: 1_000_000 + 120) + #expect(activity == expected) + } + + @Test("Capacity overflow throws") + func capacityOverflow() async throws { + let policy = MCPSessionPolicy( + idleTimeout: .seconds(900), + maxSessions: 2, + cleanupInterval: .seconds(60) + ) + let store = MCPSessionStore(policy: policy) + _ = try await store.create() + _ = try await store.create() + await #expect(throws: MCPSessionStoreError.self) { + _ = try await store.create() + } + } + + @Test("Idle eviction terminates expired sessions") + func idleEviction() async throws { + let clock = MCPTestClock(start: Date(timeIntervalSince1970: 1_000_000)) + let policy = MCPSessionPolicy( + idleTimeout: .seconds(300), + maxSessions: 16, + cleanupInterval: .seconds(60) + ) + let store = MCPSessionStore(policy: policy, clock: clock) + let active = try await store.create() + let stale = try await store.create() + + await clock.advance(by: .seconds(200)) + await store.touch(id: active.id) + + await clock.advance(by: .seconds(200)) + await store.runCleanupPass() + + let activeFound = await store.session(id: active.id) + let staleFound = await store.session(id: stale.id) + #expect(activeFound != nil) + #expect(staleFound == nil) + } + + @Test("Termination broadcasts to subscribers") + func terminationBroadcastsEvents() async throws { + let store = MCPSessionStore() + let stream = await store.events + + let session = try await store.create() + await store.terminate(id: session.id, reason: .clientRequested) + + var collected: [MCPSessionEvent] = [] + var iterator = stream.makeAsyncIterator() + if let event = await iterator.next() { + collected.append(event) + } + if let event = await iterator.next() { + collected.append(event) + } + + #expect(collected.count == 2) + guard case .created(let createdId) = collected[0] else { + Issue.record("Expected created event, got \(collected[0])") + return + } + guard case .terminated(let terminatedId, let reason) = collected[1] else { + Issue.record("Expected terminated event, got \(collected[1])") + return + } + #expect(createdId == session.id) + #expect(terminatedId == session.id) + #expect(reason == .clientRequested) + } + + @Test("Multiple subscribers receive same events") + func multipleSubscribersReceiveSameEvents() async throws { + let store = MCPSessionStore() + let streamA = await store.events + let streamB = await store.events + + let session = try await store.create() + await store.terminate(id: session.id, reason: .idleTimeout) + + var iteratorA = streamA.makeAsyncIterator() + var iteratorB = streamB.makeAsyncIterator() + + let firstA = await iteratorA.next() + let firstB = await iteratorB.next() + #expect(firstA != nil) + #expect(firstB != nil) + + let secondA = await iteratorA.next() + let secondB = await iteratorB.next() + guard case .terminated(_, let reasonA) = secondA else { + Issue.record("Expected terminated for A") + return + } + guard case .terminated(_, let reasonB) = secondB else { + Issue.record("Expected terminated for B") + return + } + #expect(reasonA == .idleTimeout) + #expect(reasonB == .idleTimeout) + } + + @Test("Terminate on missing id is a no-op") + func terminateMissingIsNoop() async { + let store = MCPSessionStore() + let unknown = MCPSessionId.generate() + await store.terminate(id: unknown, reason: .clientRequested) + let count = await store.count() + #expect(count == 0) + } + + @Test("Cleanup pass with no idle sessions does nothing") + func cleanupNoIdle() async throws { + let clock = MCPTestClock() + let policy = MCPSessionPolicy( + idleTimeout: .seconds(900), + maxSessions: 8, + cleanupInterval: .seconds(60) + ) + let store = MCPSessionStore(policy: policy, clock: clock) + let session = try await store.create() + await clock.advance(by: .seconds(60)) + await store.runCleanupPass() + let found = await store.session(id: session.id) + #expect(found != nil) + } + + @Test("Idle eviction emits idleTimeout event") + func idleEvictionEmitsTimeoutEvent() async throws { + let clock = MCPTestClock(start: Date(timeIntervalSince1970: 2_000_000)) + let policy = MCPSessionPolicy( + idleTimeout: .seconds(60), + maxSessions: 4, + cleanupInterval: .seconds(15) + ) + let store = MCPSessionStore(policy: policy, clock: clock) + let stream = await store.events + let session = try await store.create() + + await clock.advance(by: .seconds(120)) + await store.runCleanupPass() + + var iterator = stream.makeAsyncIterator() + _ = await iterator.next() + let terminationEvent = await iterator.next() + guard case .terminated(let id, let reason) = terminationEvent else { + Issue.record("Expected terminated event, got \(String(describing: terminationEvent))") + return + } + #expect(id == session.id) + #expect(reason == .idleTimeout) + } +} diff --git a/TableProTests/Core/MCP/Session/MCPSessionTests.swift b/TableProTests/Core/MCP/Session/MCPSessionTests.swift new file mode 100644 index 000000000..20b3ef2df --- /dev/null +++ b/TableProTests/Core/MCP/Session/MCPSessionTests.swift @@ -0,0 +1,95 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("MCP Session") +struct MCPSessionTests { + @Test("New session starts in initializing state") + func newSessionStartsInitializing() async { + let session = MCPSession() + let state = await session.state + #expect(state == .initializing) + } + + @Test("Transition initializing to ready succeeds") + func transitionInitializingToReady() async throws { + let session = MCPSession() + try await session.transitionToReady() + let state = await session.state + #expect(state == .ready) + } + + @Test("Cannot transition to ready twice") + func cannotTransitionToReadyTwice() async throws { + let session = MCPSession() + try await session.transitionToReady() + await #expect(throws: MCPSessionTransitionError.self) { + try await session.transitionToReady() + } + } + + @Test("Cannot transition to ready after termination") + func cannotTransitionAfterTermination() async { + let session = MCPSession() + await session.terminate(reason: .clientRequested) + await #expect(throws: MCPSessionTransitionError.self) { + try await session.transitionToReady() + } + } + + @Test("Touch updates last activity for non-terminated sessions") + func touchUpdatesLastActivity() async { + let start = Date(timeIntervalSince1970: 1_000_000) + let session = MCPSession(now: start) + let later = start.addingTimeInterval(30) + await session.touch(now: later) + let activity = await session.lastActivityAt + #expect(activity == later) + } + + @Test("Touch is ignored after termination") + func touchIgnoredAfterTermination() async { + let start = Date(timeIntervalSince1970: 1_000_000) + let session = MCPSession(now: start) + await session.terminate(reason: .idleTimeout) + let later = start.addingTimeInterval(60) + await session.touch(now: later) + let activity = await session.lastActivityAt + #expect(activity == start) + } + + @Test("recordInitialize stores client info and capabilities") + func recordInitializeStoresInfo() async { + let session = MCPSession() + let info = MCPClientInfo(name: "Claude", version: "1.0") + await session.recordInitialize( + clientInfo: info, + protocolVersion: "2024-11-05", + capabilities: .object(["sampling": .object([:])]) + ) + let stored = await session.clientInfo + let version = await session.negotiatedProtocolVersion + #expect(stored == info) + #expect(version == "2024-11-05") + } + + @Test("Snapshot reflects current state") + func snapshotReflectsState() async throws { + let session = MCPSession() + try await session.transitionToReady() + let info = MCPClientInfo(name: "TestClient", version: nil) + await session.recordInitialize(clientInfo: info, protocolVersion: "v1", capabilities: nil) + let snapshot = await session.snapshot() + #expect(snapshot.state == .ready) + #expect(snapshot.clientInfo == info) + } + + @Test("Termination is idempotent") + func terminationIsIdempotent() async { + let session = MCPSession() + await session.terminate(reason: .clientRequested) + await session.terminate(reason: .idleTimeout) + let state = await session.state + #expect(state == .terminated(reason: .clientRequested)) + } +} diff --git a/TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift b/TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift new file mode 100644 index 000000000..01666d9a9 --- /dev/null +++ b/TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift @@ -0,0 +1,129 @@ +import Foundation +@testable import TablePro +import XCTest + +final class HttpRequestParserTests: XCTestCase { + func testParsesSimpleGetRequest() throws { + let raw = "GET /index HTTP/1.1\r\nHost: example.com\r\n\r\n" + let result = try HttpRequestParser.parse(Data(raw.utf8)) + guard case .complete(let head, let body, let consumed) = result else { + XCTFail("Expected complete, got \(result)") + return + } + XCTAssertEqual(head.method, .get) + XCTAssertEqual(head.path, "/index") + XCTAssertEqual(head.httpVersion, "HTTP/1.1") + XCTAssertEqual(head.headers.value(for: "Host"), "example.com") + XCTAssertEqual(body, Data()) + XCTAssertEqual(consumed, raw.utf8.count) + } + + func testCaseInsensitiveHeaderLookup() throws { + let raw = "GET / HTTP/1.1\r\nContent-Type: text/plain\r\n\r\n" + let result = try HttpRequestParser.parse(Data(raw.utf8)) + guard case .complete(let head, _, _) = result else { + XCTFail("Expected complete") + return + } + XCTAssertEqual(head.headers.value(for: "content-type"), "text/plain") + XCTAssertEqual(head.headers.value(for: "CONTENT-TYPE"), "text/plain") + } + + func testParsesPostBodyOfExactContentLength() throws { + let body = "{\"x\":1}" + let raw = "POST /rpc HTTP/1.1\r\nHost: x\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)" + let result = try HttpRequestParser.parse(Data(raw.utf8)) + guard case .complete(let head, let parsedBody, let consumed) = result else { + XCTFail("Expected complete") + return + } + XCTAssertEqual(head.method, .post) + XCTAssertEqual(parsedBody, Data(body.utf8)) + XCTAssertEqual(consumed, raw.utf8.count) + } + + func testReportsExtraBytesAfterBodyViaConsumedBytes() throws { + let body = "abc" + let raw = "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 3\r\n\r\n\(body)REMAINDER" + let result = try HttpRequestParser.parse(Data(raw.utf8)) + guard case .complete(_, let parsedBody, let consumed) = result else { + XCTFail("Expected complete") + return + } + XCTAssertEqual(parsedBody, Data(body.utf8)) + let expectedConsumed = raw.utf8.count - "REMAINDER".utf8.count + XCTAssertEqual(consumed, expectedConsumed) + } + + func testIncompleteWhenHeadersNotFinished() throws { + let raw = "GET / HTTP/1.1\r\nHost: x" + let result = try HttpRequestParser.parse(Data(raw.utf8)) + XCTAssertEqual(result, .incomplete) + } + + func testIncompleteWhenBodyShorterThanContentLength() throws { + let raw = "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\n\r\nshort" + let result = try HttpRequestParser.parse(Data(raw.utf8)) + XCTAssertEqual(result, .incomplete) + } + + func testRejectsBareLfAsTerminator() { + let raw = "GET / HTTP/1.1\nHost: x\n\n" + XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in + XCTAssertEqual(error as? HttpRequestParseError, .nonStrictLineEndings) + } + } + + func testRejectsBareLfInHeaderLine() { + let raw = "GET / HTTP/1.1\r\nBad: value\nHost: x\r\n\r\n" + XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in + XCTAssertEqual(error as? HttpRequestParseError, .nonStrictLineEndings) + } + } + + func testRejectsHeaderTooLarge() { + let bigHeaderValue = String(repeating: "a", count: 17 * 1_024) + let raw = "GET / HTTP/1.1\r\nX-Big: \(bigHeaderValue)\r\n\r\n" + XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in + XCTAssertEqual(error as? HttpRequestParseError, .headerTooLarge) + } + } + + func testRejectsHeaderTooLargeWithoutTerminator() { + let huge = String(repeating: "X-Pad: pad\r\n", count: 2_000) + let raw = "GET / HTTP/1.1\r\n\(huge)" + XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in + XCTAssertEqual(error as? HttpRequestParseError, .headerTooLarge) + } + } + + func testUnknownMethodMappedToOther() throws { + let raw = "PROPFIND / HTTP/1.1\r\nHost: x\r\n\r\n" + let result = try HttpRequestParser.parse(Data(raw.utf8)) + guard case .complete(let head, _, _) = result else { + XCTFail("Expected complete") + return + } + XCTAssertEqual(head.method, .other("PROPFIND")) + } + + func testRejectsBodyOverLimit() { + let raw = "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 99999999\r\n\r\n" + XCTAssertThrowsError(try HttpRequestParser.parse(Data(raw.utf8))) { error in + guard case HttpRequestParseError.bodyTooLarge = error else { + XCTFail("Expected bodyTooLarge") + return + } + } + } + + func testPathPreservedVerbatim() throws { + let raw = "GET /path%20with%20spaces?x=1 HTTP/1.1\r\nHost: x\r\n\r\n" + let result = try HttpRequestParser.parse(Data(raw.utf8)) + guard case .complete(let head, _, _) = result else { + XCTFail("Expected complete") + return + } + XCTAssertEqual(head.path, "/path%20with%20spaces?x=1") + } +} diff --git a/TableProTests/Core/MCP/Wire/JsonRpcIdTests.swift b/TableProTests/Core/MCP/Wire/JsonRpcIdTests.swift new file mode 100644 index 000000000..0e24d42a1 --- /dev/null +++ b/TableProTests/Core/MCP/Wire/JsonRpcIdTests.swift @@ -0,0 +1,60 @@ +import Foundation +@testable import TablePro +import XCTest + +final class JsonRpcIdTests: XCTestCase { + func testNullRoundTrip() throws { + let id: JsonRpcId = .null + let data = try JSONEncoder().encode(id) + let decoded = try JSONDecoder().decode(JsonRpcId.self, from: data) + XCTAssertEqual(decoded, .null) + } + + func testNullEncodesAsJsonNull() throws { + let id: JsonRpcId = .null + let data = try JSONEncoder().encode(id) + XCTAssertEqual(String(data: data, encoding: .utf8), "null") + } + + func testStringRoundTrip() throws { + let id: JsonRpcId = .string("abc-123") + let data = try JSONEncoder().encode(id) + let decoded = try JSONDecoder().decode(JsonRpcId.self, from: data) + XCTAssertEqual(decoded, .string("abc-123")) + } + + func testNumberRoundTrip() throws { + let id: JsonRpcId = .number(42) + let data = try JSONEncoder().encode(id) + let decoded = try JSONDecoder().decode(JsonRpcId.self, from: data) + XCTAssertEqual(decoded, .number(42)) + } + + func testLargeNumberRoundTrip() throws { + let id: JsonRpcId = .number(Int64.max) + let data = try JSONEncoder().encode(id) + let decoded = try JSONDecoder().decode(JsonRpcId.self, from: data) + XCTAssertEqual(decoded, .number(Int64.max)) + } + + func testDecodeJsonNullProducesNullCase() throws { + let raw = Data("null".utf8) + let decoded = try JSONDecoder().decode(JsonRpcId.self, from: raw) + XCTAssertEqual(decoded, .null) + } + + func testDecodeBoolThrows() { + let raw = Data("true".utf8) + XCTAssertThrowsError(try JSONDecoder().decode(JsonRpcId.self, from: raw)) + } + + func testDecodeArrayThrows() { + let raw = Data("[1,2]".utf8) + XCTAssertThrowsError(try JSONDecoder().decode(JsonRpcId.self, from: raw)) + } + + func testDecodeObjectThrows() { + let raw = Data("{}".utf8) + XCTAssertThrowsError(try JSONDecoder().decode(JsonRpcId.self, from: raw)) + } +} diff --git a/TableProTests/Core/MCP/Wire/JsonRpcMessageTests.swift b/TableProTests/Core/MCP/Wire/JsonRpcMessageTests.swift new file mode 100644 index 000000000..02bc4e900 --- /dev/null +++ b/TableProTests/Core/MCP/Wire/JsonRpcMessageTests.swift @@ -0,0 +1,206 @@ +import Foundation +@testable import TablePro +import XCTest + +final class JsonRpcMessageTests: XCTestCase { + func testRequestRoundTrip() throws { + let message = JsonRpcMessage.request( + JsonRpcRequest( + id: .number(1), + method: "tools/list", + params: .object(["cursor": .string("abc")]) + ) + ) + let data = try JsonRpcCodec.encode(message) + let decoded = try JsonRpcCodec.decode(data) + XCTAssertEqual(decoded, message) + } + + func testRequestWithoutParamsRoundTrip() throws { + let message = JsonRpcMessage.request( + JsonRpcRequest(id: .string("req-1"), method: "ping", params: nil) + ) + let data = try JsonRpcCodec.encode(message) + let decoded = try JsonRpcCodec.decode(data) + XCTAssertEqual(decoded, message) + + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + XCTAssertFalse(json.contains("\"params\"")) + } + + func testNotificationRoundTrip() throws { + let message = JsonRpcMessage.notification( + JsonRpcNotification(method: "notifications/initialized", params: nil) + ) + let data = try JsonRpcCodec.encode(message) + let decoded = try JsonRpcCodec.decode(data) + XCTAssertEqual(decoded, message) + + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + XCTAssertFalse(json.contains("\"id\"")) + XCTAssertFalse(json.contains("\"params\"")) + } + + func testNotificationWithParamsRoundTrip() throws { + let message = JsonRpcMessage.notification( + JsonRpcNotification( + method: "notifications/progress", + params: .object(["progress": .int(50)]) + ) + ) + let data = try JsonRpcCodec.encode(message) + let decoded = try JsonRpcCodec.decode(data) + XCTAssertEqual(decoded, message) + } + + func testSuccessResponseRoundTrip() throws { + let message = JsonRpcMessage.successResponse( + JsonRpcSuccessResponse( + id: .number(7), + result: .object(["tools": .array([])]) + ) + ) + let data = try JsonRpcCodec.encode(message) + let decoded = try JsonRpcCodec.decode(data) + XCTAssertEqual(decoded, message) + } + + func testErrorResponseRoundTrip() throws { + let message = JsonRpcMessage.errorResponse( + JsonRpcErrorResponse( + id: .number(8), + error: JsonRpcError.methodNotFound(message: "not here") + ) + ) + let data = try JsonRpcCodec.encode(message) + let decoded = try JsonRpcCodec.decode(data) + XCTAssertEqual(decoded, message) + } + + func testErrorResponseWithNullIdEncodesAsJsonNull() throws { + let message = JsonRpcMessage.errorResponse( + JsonRpcErrorResponse(id: nil, error: JsonRpcError.parseError()) + ) + let data = try JsonRpcCodec.encode(message) + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + XCTAssertTrue(json.contains("\"id\":null")) + } + + func testErrorResponseWithExplicitNullIdRoundTrips() throws { + let message = JsonRpcMessage.errorResponse( + JsonRpcErrorResponse(id: .null, error: JsonRpcError.serverError()) + ) + let data = try JsonRpcCodec.encode(message) + let decoded = try JsonRpcCodec.decode(data) + if case .errorResponse(let response) = decoded { + XCTAssertEqual(response.id, .null) + } else { + XCTFail("Expected errorResponse") + } + } + + func testErrorResponseDataRoundTrip() throws { + let message = JsonRpcMessage.errorResponse( + JsonRpcErrorResponse( + id: .number(9), + error: JsonRpcError( + code: JsonRpcErrorCode.forbidden, + message: "no access", + data: .object(["reason": .string("policy")]) + ) + ) + ) + let data = try JsonRpcCodec.encode(message) + let decoded = try JsonRpcCodec.decode(data) + XCTAssertEqual(decoded, message) + } + + func testErrorResponseWithoutDataOmitsField() throws { + let message = JsonRpcMessage.errorResponse( + JsonRpcErrorResponse( + id: .number(10), + error: JsonRpcError.methodNotFound() + ) + ) + let data = try JsonRpcCodec.encode(message) + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + XCTAssertFalse(json.contains("\"data\"")) + } + + func testRejectsNon20JsonRpcVersion() { + let raw = Data(#"{"jsonrpc":"1.0","id":1,"method":"ping"}"#.utf8) + XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in + guard case JsonRpcDecodingError.invalidJsonRpcVersion(let value) = error else { + XCTFail("Expected invalidJsonRpcVersion, got \(error)") + return + } + XCTAssertEqual(value, "1.0") + } + } + + func testRejectsMissingJsonRpcVersion() { + let raw = Data(#"{"id":1,"method":"ping"}"#.utf8) + XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in + XCTAssertEqual(error as? JsonRpcDecodingError, .missingJsonRpcVersion) + } + } + + func testRejectsBatchArray() { + let raw = Data(#"[{"jsonrpc":"2.0","id":1,"method":"ping"}]"#.utf8) + XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in + XCTAssertEqual(error as? JsonRpcDecodingError, .batchUnsupported) + } + } + + func testRejectsBatchArrayWithLeadingWhitespace() { + let raw = Data(" \n[{\"jsonrpc\":\"2.0\"}]".utf8) + XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in + XCTAssertEqual(error as? JsonRpcDecodingError, .batchUnsupported) + } + } + + func testEncodeLineAppendsNewline() throws { + let message = JsonRpcMessage.notification( + JsonRpcNotification(method: "ping", params: nil) + ) + let data = try JsonRpcCodec.encodeLine(message) + XCTAssertEqual(data.last, 0x0A) + } + + func testNullIdInRequestRoundTrips() throws { + let message = JsonRpcMessage.request( + JsonRpcRequest(id: .null, method: "test", params: nil) + ) + let data = try JsonRpcCodec.encode(message) + let decoded = try JsonRpcCodec.decode(data) + XCTAssertEqual(decoded, message) + } + + func testRejectsAmbiguousMessageWithMethodAndResult() { + let raw = Data(#"{"jsonrpc":"2.0","id":1,"method":"foo","result":1}"#.utf8) + XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in + XCTAssertEqual(error as? JsonRpcDecodingError, .ambiguousMessage) + } + } + + func testRejectsResultAndError() { + let raw = Data(#"{"jsonrpc":"2.0","id":1,"result":1,"error":{"code":-32000,"message":"x"}}"#.utf8) + XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in + XCTAssertEqual(error as? JsonRpcDecodingError, .ambiguousMessage) + } + } + + func testRejectsEmptyEnvelope() { + let raw = Data(#"{"jsonrpc":"2.0","id":1}"#.utf8) + XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in + XCTAssertEqual(error as? JsonRpcDecodingError, .missingResultOrError) + } + } + + func testRejectsEnvelopeWithoutMethodOrIdEvenWithVersion() { + let raw = Data(#"{"jsonrpc":"2.0"}"#.utf8) + XCTAssertThrowsError(try JsonRpcCodec.decode(raw)) { error in + XCTAssertEqual(error as? JsonRpcDecodingError, .missingMethod) + } + } +} diff --git a/TableProTests/Core/MCP/Wire/SseEncoderDecoderTests.swift b/TableProTests/Core/MCP/Wire/SseEncoderDecoderTests.swift new file mode 100644 index 000000000..e4c08d3a0 --- /dev/null +++ b/TableProTests/Core/MCP/Wire/SseEncoderDecoderTests.swift @@ -0,0 +1,104 @@ +import Foundation +@testable import TablePro +import XCTest + +final class SseEncoderDecoderTests: XCTestCase { + func testRoundTripSingleLineFrame() async throws { + let frame = SseFrame(event: "message", id: "1", data: "hello", retry: nil) + let encoded = SseEncoder.encode(frame) + let decoder = SseDecoder() + let frames = await decoder.feed(encoded) + XCTAssertEqual(frames.count, 1) + XCTAssertEqual(frames.first?.event, "message") + XCTAssertEqual(frames.first?.id, "1") + XCTAssertEqual(frames.first?.data, "hello") + } + + func testEncodeMultiLineDataProducesMultipleDataLines() { + let frame = SseFrame(data: "line1\nline2\nline3") + let encoded = SseEncoder.encode(frame) + let text = String(data: encoded, encoding: .utf8) ?? "" + XCTAssertTrue(text.contains("data: line1\n")) + XCTAssertTrue(text.contains("data: line2\n")) + XCTAssertTrue(text.contains("data: line3\n")) + XCTAssertTrue(text.hasSuffix("\n\n")) + } + + func testRoundTripMultiLineData() async throws { + let frame = SseFrame(data: "alpha\nbeta\ngamma") + let encoded = SseEncoder.encode(frame) + let decoder = SseDecoder() + let frames = await decoder.feed(encoded) + XCTAssertEqual(frames.count, 1) + XCTAssertEqual(frames.first?.data, "alpha\nbeta\ngamma") + } + + func testDecodesMultipleFramesInOneChunk() async throws { + let frameA = SseEncoder.encode(SseFrame(event: "a", data: "first")) + let frameB = SseEncoder.encode(SseFrame(event: "b", data: "second")) + var combined = Data() + combined.append(frameA) + combined.append(frameB) + + let decoder = SseDecoder() + let frames = await decoder.feed(combined) + XCTAssertEqual(frames.count, 2) + XCTAssertEqual(frames[0].data, "first") + XCTAssertEqual(frames[1].data, "second") + } + + func testBuffersPartialFramesAcrossChunks() async throws { + let frame = SseFrame(event: "ping", data: "hello world") + let encoded = SseEncoder.encode(frame) + + let split = encoded.count / 2 + let firstPart = encoded.prefix(split) + let secondPart = encoded.suffix(from: split) + + let decoder = SseDecoder() + let firstFrames = await decoder.feed(Data(firstPart)) + XCTAssertTrue(firstFrames.isEmpty) + let secondFrames = await decoder.feed(Data(secondPart)) + XCTAssertEqual(secondFrames.count, 1) + XCTAssertEqual(secondFrames.first?.data, "hello world") + } + + func testDecoderToleratesCrlfFieldSeparators() async throws { + let raw = "event: x\r\nid: 7\r\ndata: hi\r\n\r\n" + let decoder = SseDecoder() + let frames = await decoder.feed(Data(raw.utf8)) + XCTAssertEqual(frames.count, 1) + XCTAssertEqual(frames.first?.event, "x") + XCTAssertEqual(frames.first?.id, "7") + XCTAssertEqual(frames.first?.data, "hi") + } + + func testDecoderJoinsMultipleDataFieldsWithNewline() async throws { + let raw = "data: a\ndata: b\ndata: c\n\n" + let decoder = SseDecoder() + let frames = await decoder.feed(Data(raw.utf8)) + XCTAssertEqual(frames.count, 1) + XCTAssertEqual(frames.first?.data, "a\nb\nc") + } + + func testDecoderIgnoresCommentLines() async throws { + let raw = ": this is a comment\ndata: payload\n\n" + let decoder = SseDecoder() + let frames = await decoder.feed(Data(raw.utf8)) + XCTAssertEqual(frames.count, 1) + XCTAssertEqual(frames.first?.data, "payload") + } + + func testEncoderIncludesRetry() { + let frame = SseFrame(data: "ping", retry: 5_000) + let encoded = SseEncoder.encode(frame) + let text = String(data: encoded, encoding: .utf8) ?? "" + XCTAssertTrue(text.contains("retry: 5000\n")) + } + + func testEncoderEndsWithDoubleNewline() { + let frame = SseFrame(data: "x") + let encoded = SseEncoder.encode(frame) + XCTAssertEqual(encoded.suffix(2), Data([0x0A, 0x0A])) + } +} diff --git a/TableProTests/Views/Main/TableRowsMutationTests.swift b/TableProTests/Views/Main/TableRowsMutationTests.swift index c0272162a..ae6bd7a98 100644 --- a/TableProTests/Views/Main/TableRowsMutationTests.swift +++ b/TableProTests/Views/Main/TableRowsMutationTests.swift @@ -79,7 +79,7 @@ struct TableRowsMutationTests { @Test("setActiveTableRows on the active tab dispatches applyFullReplace") func dispatchesOnActiveTab() throws { let f = makeFixture() - f.try tabManager.addTableTab(tableName: "users") + try f.tabManager.addTableTab(tableName: "users") let activeTabId = f.tabManager.tabs[0].id f.coordinator.setActiveTableRows(makeTableRows(rowCount: 3), for: activeTabId) @@ -90,9 +90,9 @@ struct TableRowsMutationTests { @Test("setActiveTableRows on a background tab does not dispatch") func skipsOnBackgroundTab() throws { let f = makeFixture() - f.try tabManager.addTableTab(tableName: "users") + try f.tabManager.addTableTab(tableName: "users") let backgroundTabId = f.tabManager.tabs[0].id - f.try tabManager.addTableTab(tableName: "orders") + try f.tabManager.addTableTab(tableName: "orders") f.coordinator.setActiveTableRows(makeTableRows(rowCount: 5), for: backgroundTabId) @@ -102,7 +102,7 @@ struct TableRowsMutationTests { @Test("repeated setActiveTableRows dispatches once per call") func dispatchesOncePerCall() throws { let f = makeFixture() - f.try tabManager.addTableTab(tableName: "users") + try f.tabManager.addTableTab(tableName: "users") let activeTabId = f.tabManager.tabs[0].id f.coordinator.setActiveTableRows(TableRows(), for: activeTabId) @@ -114,7 +114,7 @@ struct TableRowsMutationTests { @Test("setActiveTableRows dispatches scrollToTop when pendingScrollToTopAfterReplace contains tabId") func scrollToTopFiresOnPendingFlag() throws { let f = makeFixture() - f.try tabManager.addTableTab(tableName: "users") + try f.tabManager.addTableTab(tableName: "users") let activeTabId = f.tabManager.tabs[0].id f.coordinator.pendingScrollToTopAfterReplace.insert(activeTabId) @@ -127,9 +127,9 @@ struct TableRowsMutationTests { @Test("scrollToTop pending flag for tab A does not fire when tab B is replaced") func scrollToTopFlagIsScopedPerTab() throws { let f = makeFixture() - f.try tabManager.addTableTab(tableName: "users") + try f.tabManager.addTableTab(tableName: "users") let firstTabId = f.tabManager.tabs[0].id - f.try tabManager.addTableTab(tableName: "orders") + try f.tabManager.addTableTab(tableName: "orders") let secondTabId = f.tabManager.tabs[1].id f.coordinator.pendingScrollToTopAfterReplace.insert(firstTabId) @@ -142,7 +142,7 @@ struct TableRowsMutationTests { @Test("setActiveTableRows without pending flag does not scroll to top") func scrollToTopSkippedWhenFlagAbsent() throws { let f = makeFixture() - f.try tabManager.addTableTab(tableName: "users") + try f.tabManager.addTableTab(tableName: "users") let activeTabId = f.tabManager.tabs[0].id f.coordinator.setActiveTableRows(makeTableRows(rowCount: 3), for: activeTabId) From ae75a8d77e6eda78d14fae7146f29b70569bc4bc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 04:50:43 +0700 Subject: [PATCH 02/54] refactor(mcp): phase 2 transport layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new transports under Core/MCP/Transport/, all built on the new JsonRpcMessage type. Bridge transports (StdioMessageTransport, Streamable HttpClientTransport) conform to MCPMessageTransport — symmetric inbound stream + send. The stdio transport reads via FileHandle.bytes (no more availableData polling) and writes only valid JSON-RPC to stdout. The streaming HTTP client uses URLSession.bytes for incremental SSE parsing and synthesizes JSON-RPC error envelopes from non-2xx HTTP responses so no malformed line ever reaches the host's stdin — fixing the bridge bug at the architecture level. The in-app HTTP server transport (MCPHttpServerTransport) is exchange- based: each inbound POST yields an MCPInboundExchange whose responder handles wire-level details. Every error path now produces a JSON-RPC envelope; the previous bare {"error":...} body shape is impossible to emit by construction. CORS now includes Last-Event-ID. Remote access without TLS is rejected at configuration time, not at runtime. MCPProtocolError carries both JSON-RPC code and HTTP status; static factories (sessionNotFound, unauthenticated, etc.) encode the spec- correct mappings in one place. --- .../Core/MCP/Transport/MCPBridgeLogger.swift | 83 ++ .../Core/MCP/Transport/MCPCorsHeaders.swift | 27 + .../MCPHttpServerConfiguration.swift | 95 ++ .../MCP/Transport/MCPHttpServerError.swift | 9 + .../Transport/MCPHttpServerTransport.swift | 865 ++++++++++++++++++ .../MCP/Transport/MCPInboundExchange.swift | 146 +++ .../MCP/Transport/MCPMessageTransport.swift | 18 + .../Core/MCP/Transport/MCPProtocolError.swift | 158 ++++ .../Transport/MCPStdioMessageTransport.swift | 154 ++++ .../MCPStreamableHttpClientTransport.swift | 447 +++++++++ .../MCP/Helpers/MCPTransportTestStubs.swift | 97 ++ .../MCPHttpServerConfigurationTests.swift | 72 ++ .../MCPHttpServerTransportTests.swift | 416 +++++++++ .../MCP/Transport/MCPProtocolErrorTests.swift | 126 +++ .../MCPStdioMessageTransportTests.swift | 181 ++++ ...CPStreamableHttpClientTransportTests.swift | 528 +++++++++++ 16 files changed, 3422 insertions(+) create mode 100644 TablePro/Core/MCP/Transport/MCPBridgeLogger.swift create mode 100644 TablePro/Core/MCP/Transport/MCPCorsHeaders.swift create mode 100644 TablePro/Core/MCP/Transport/MCPHttpServerConfiguration.swift create mode 100644 TablePro/Core/MCP/Transport/MCPHttpServerError.swift create mode 100644 TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift create mode 100644 TablePro/Core/MCP/Transport/MCPInboundExchange.swift create mode 100644 TablePro/Core/MCP/Transport/MCPMessageTransport.swift create mode 100644 TablePro/Core/MCP/Transport/MCPProtocolError.swift create mode 100644 TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift create mode 100644 TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift create mode 100644 TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift create mode 100644 TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift create mode 100644 TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift create mode 100644 TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift create mode 100644 TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift create mode 100644 TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift diff --git a/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift b/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift new file mode 100644 index 000000000..80e8d6ee3 --- /dev/null +++ b/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift @@ -0,0 +1,83 @@ +import Foundation +import os + +public enum MCPBridgeLogLevel: String, Sendable { + case debug + case info + case warning + case error +} + +public protocol MCPBridgeLogger: Sendable { + func log(_ level: MCPBridgeLogLevel, _ message: String) +} + +public struct MCPOSBridgeLogger: MCPBridgeLogger { + private let logger: Logger + + public init(subsystem: String = "com.TablePro", category: String = "MCP.Bridge") { + logger = Logger(subsystem: subsystem, category: category) + } + + public func log(_ level: MCPBridgeLogLevel, _ message: String) { + switch level { + case .debug: + logger.debug("\(message, privacy: .public)") + case .info: + logger.info("\(message, privacy: .public)") + case .warning: + logger.warning("\(message, privacy: .public)") + case .error: + logger.error("\(message, privacy: .public)") + } + } +} + +public struct MCPStderrBridgeLogger: MCPBridgeLogger { + private let writer: StderrWriter + + public init() { + writer = StderrWriter() + } + + public func log(_ level: MCPBridgeLogLevel, _ message: String) { + let prefix: String + switch level { + case .debug: prefix = "[debug] " + case .info: prefix = "[info] " + case .warning: prefix = "[warn] " + case .error: prefix = "[error] " + } + writer.write(prefix + message + "\n") + } +} + +public struct MCPCompositeBridgeLogger: MCPBridgeLogger { + private let loggers: [MCPBridgeLogger] + + public init(_ loggers: [MCPBridgeLogger]) { + self.loggers = loggers + } + + public func log(_ level: MCPBridgeLogLevel, _ message: String) { + for logger in loggers { + logger.log(level, message) + } + } +} + +private final class StderrWriter: @unchecked Sendable { + private let lock = NSLock() + private let handle: FileHandle + + init(handle: FileHandle = .standardError) { + self.handle = handle + } + + func write(_ string: String) { + guard let data = string.data(using: .utf8) else { return } + lock.lock() + defer { lock.unlock() } + handle.write(data) + } +} diff --git a/TablePro/Core/MCP/Transport/MCPCorsHeaders.swift b/TablePro/Core/MCP/Transport/MCPCorsHeaders.swift new file mode 100644 index 000000000..a6d136f56 --- /dev/null +++ b/TablePro/Core/MCP/Transport/MCPCorsHeaders.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum MCPCorsHeaders { + public static let standard: [(String, String)] = [ + ("Access-Control-Allow-Origin", "http://localhost"), + ("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"), + ( + "Access-Control-Allow-Headers", + "Content-Type, Mcp-Session-Id, mcp-protocol-version, Authorization, Last-Event-ID" + ), + ("Access-Control-Expose-Headers", "Mcp-Session-Id"), + ("Access-Control-Max-Age", "86400") + ] + + public static func corsHeaders(allowingOrigin origin: String) -> [(String, String)] { + [ + ("Access-Control-Allow-Origin", origin), + ("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"), + ( + "Access-Control-Allow-Headers", + "Content-Type, Mcp-Session-Id, mcp-protocol-version, Authorization, Last-Event-ID" + ), + ("Access-Control-Expose-Headers", "Mcp-Session-Id"), + ("Access-Control-Max-Age", "86400") + ] + } +} diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerConfiguration.swift b/TablePro/Core/MCP/Transport/MCPHttpServerConfiguration.swift new file mode 100644 index 000000000..0247301d0 --- /dev/null +++ b/TablePro/Core/MCP/Transport/MCPHttpServerConfiguration.swift @@ -0,0 +1,95 @@ +import Foundation +@preconcurrency import Security + +public enum MCPBindAddress: Sendable, Equatable { + case loopback + case anyInterface +} + +public enum TLSProtocolVersion: Sendable, Equatable { + case tls12 + case tls13 +} + +public struct MCPTLSConfiguration: Sendable { + public let identity: SecIdentity + public let minimumProtocol: TLSProtocolVersion + + public init(identity: SecIdentity, minimumProtocol: TLSProtocolVersion = .tls12) { + self.identity = identity + self.minimumProtocol = minimumProtocol + } +} + +public struct MCPHttpServerLimits: Sendable, Equatable { + public let maxRequestBodyBytes: Int + public let maxHeaderBytes: Int + public let connectionTimeout: Duration + + public init( + maxRequestBodyBytes: Int, + maxHeaderBytes: Int, + connectionTimeout: Duration + ) { + self.maxRequestBodyBytes = maxRequestBodyBytes + self.maxHeaderBytes = maxHeaderBytes + self.connectionTimeout = connectionTimeout + } + + public static let standard = MCPHttpServerLimits( + maxRequestBodyBytes: 10 * 1_024 * 1_024, + maxHeaderBytes: 16 * 1_024, + connectionTimeout: .seconds(30) + ) +} + +public struct MCPHttpServerConfiguration: Sendable { + public let bindAddress: MCPBindAddress + public let port: UInt16 + public let tls: MCPTLSConfiguration? + public let limits: MCPHttpServerLimits + + private init( + bindAddress: MCPBindAddress, + port: UInt16, + tls: MCPTLSConfiguration?, + limits: MCPHttpServerLimits + ) { + self.bindAddress = bindAddress + self.port = port + self.tls = tls + self.limits = limits + } + + public static func loopback( + port: UInt16, + limits: MCPHttpServerLimits = .standard + ) -> Self { + Self(bindAddress: .loopback, port: port, tls: nil, limits: limits) + } + + public static func loopback( + port: UInt16, + tls: MCPTLSConfiguration, + limits: MCPHttpServerLimits = .standard + ) -> Self { + Self(bindAddress: .loopback, port: port, tls: tls, limits: limits) + } + + public static func remote( + port: UInt16, + tls: MCPTLSConfiguration, + limits: MCPHttpServerLimits = .standard + ) -> Self { + Self(bindAddress: .anyInterface, port: port, tls: tls, limits: limits) + } + + internal static func unsafeMake( + bindAddress: MCPBindAddress, + port: UInt16, + tls: MCPTLSConfiguration?, + limits: MCPHttpServerLimits + ) -> Self { + Self(bindAddress: bindAddress, port: port, tls: tls, limits: limits) + } +} diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerError.swift b/TablePro/Core/MCP/Transport/MCPHttpServerError.swift new file mode 100644 index 000000000..7c00da248 --- /dev/null +++ b/TablePro/Core/MCP/Transport/MCPHttpServerError.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum MCPHttpServerError: Error, Sendable, Equatable { + case tlsRequiredForRemoteAccess + case alreadyStarted + case notStarted + case bindFailed(reason: String) + case acceptCancelled +} diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift new file mode 100644 index 000000000..d0c9bdc97 --- /dev/null +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -0,0 +1,865 @@ +import Foundation +import Network +import os +import Security + +public enum MCPHttpServerState: Sendable, Equatable { + case idle + case starting + case running(port: UInt16) + case stopped + case failed(reason: String) +} + +public actor MCPHttpServerTransport { + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.HttpServer") + + private let configuration: MCPHttpServerConfiguration + private let sessionStore: MCPSessionStore + private let authenticator: any MCPAuthenticator + private let clock: any MCPClock + + private var listener: NWListener? + private var connections: [UUID: HttpConnectionContext] = [:] + private var sseConnectionsBySession: [MCPSessionId: UUID] = [:] + private var sessionEventsTask: Task? + + private var exchangesContinuation: AsyncStream.Continuation? + private let exchangesStorage: AsyncStream + + private var stateContinuation: AsyncStream.Continuation? + private let listenerStateStorage: AsyncStream + + private var currentState: MCPHttpServerState = .idle + + public init( + configuration: MCPHttpServerConfiguration, + sessionStore: MCPSessionStore, + authenticator: any MCPAuthenticator, + clock: any MCPClock = MCPSystemClock() + ) { + self.configuration = configuration + self.sessionStore = sessionStore + self.authenticator = authenticator + self.clock = clock + + var exchangeContinuation: AsyncStream.Continuation? + self.exchangesStorage = AsyncStream { continuation in + exchangeContinuation = continuation + } + self.exchangesContinuation = exchangeContinuation + + var stateCont: AsyncStream.Continuation? + self.listenerStateStorage = AsyncStream { continuation in + stateCont = continuation + } + self.stateContinuation = stateCont + } + + nonisolated public var exchanges: AsyncStream { + exchangesStorage + } + + nonisolated public var listenerState: AsyncStream { + listenerStateStorage + } + + public func start() async throws { + guard listener == nil else { + throw MCPHttpServerError.alreadyStarted + } + + if configuration.bindAddress == .anyInterface, configuration.tls == nil { + throw MCPHttpServerError.tlsRequiredForRemoteAccess + } + + emitState(.starting) + + let parameters: NWParameters = makeParameters() + + let port = NWEndpoint.Port(rawValue: configuration.port) ?? .any + + do { + let newListener = try NWListener(using: parameters, on: port) + listener = newListener + + newListener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + Task { await self.handleListenerState(state) } + } + + newListener.newConnectionHandler = { [weak self] connection in + guard let self else { return } + Task { await self.handleNewConnection(connection) } + } + + newListener.start(queue: .global(qos: .userInitiated)) + startSessionEventListener() + } catch { + emitState(.failed(reason: error.localizedDescription)) + listener = nil + throw MCPHttpServerError.bindFailed(reason: error.localizedDescription) + } + } + + public func stop() async { + Self.logger.info("Stopping MCP HTTP server") + + sessionEventsTask?.cancel() + sessionEventsTask = nil + + for (_, context) in connections { + await context.cancel() + } + connections.removeAll() + sseConnectionsBySession.removeAll() + + if let listener { + self.listener = nil + await withCheckedContinuation { (continuation: CheckedContinuation) in + listener.stateUpdateHandler = { state in + if case .cancelled = state { + continuation.resume() + } + } + listener.cancel() + } + } + + emitState(.stopped) + } + + public func sendNotification(_ notification: JsonRpcNotification, toSession sessionId: MCPSessionId) async { + guard let connectionId = sseConnectionsBySession[sessionId], + let context = connections[connectionId] else { + return + } + + let message = JsonRpcMessage.notification(notification) + guard let data = try? JsonRpcCodec.encode(message), + let text = String(data: data, encoding: .utf8) else { return } + await context.writeSseFrame(SseFrame(data: text)) + } + + public func broadcastNotification(_ notification: JsonRpcNotification) async { + let sessionIds = Array(sseConnectionsBySession.keys) + for sessionId in sessionIds { + await sendNotification(notification, toSession: sessionId) + } + } + + private func makeParameters() -> NWParameters { + let tcpOptions = NWProtocolTCP.Options() + + let parameters: NWParameters + if let tls = configuration.tls { + let tlsOptions = NWProtocolTLS.Options() + if let secIdentity = sec_identity_create(tls.identity) { + sec_protocol_options_set_local_identity(tlsOptions.securityProtocolOptions, secIdentity) + } + switch tls.minimumProtocol { + case .tls12: + sec_protocol_options_set_min_tls_protocol_version(tlsOptions.securityProtocolOptions, .TLSv12) + case .tls13: + sec_protocol_options_set_min_tls_protocol_version(tlsOptions.securityProtocolOptions, .TLSv13) + } + parameters = NWParameters(tls: tlsOptions, tcp: tcpOptions) + } else { + parameters = NWParameters(tls: nil, tcp: tcpOptions) + } + + let host: NWEndpoint.Host = configuration.bindAddress == .loopback ? .ipv4(.loopback) : .ipv4(.any) + let port = NWEndpoint.Port(rawValue: configuration.port) ?? .any + parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: host, port: port) + parameters.allowLocalEndpointReuse = true + return parameters + } + + private func handleListenerState(_ state: NWListener.State) { + switch state { + case .ready: + let port = listener?.port?.rawValue ?? configuration.port + Self.logger.info("MCP HTTP server listening on port \(port, privacy: .public)") + emitState(.running(port: port)) + + case .failed(let error): + Self.logger.error("MCP HTTP listener failed: \(error.localizedDescription, privacy: .public)") + emitState(.failed(reason: error.localizedDescription)) + listener?.cancel() + listener = nil + + case .cancelled: + Self.logger.debug("MCP HTTP listener cancelled") + + default: + break + } + } + + private func emitState(_ state: MCPHttpServerState) { + currentState = state + stateContinuation?.yield(state) + } + + private func startSessionEventListener() { + sessionEventsTask?.cancel() + let store = sessionStore + sessionEventsTask = Task { [weak self] in + let eventsStream = await store.events + for await event in eventsStream { + guard let self else { return } + if case .terminated(let sessionId, let reason) = event { + await self.handleSessionTerminated(sessionId, reason: reason) + } + } + } + } + + private func handleSessionTerminated(_ sessionId: MCPSessionId, reason: MCPSessionTerminationReason) async { + guard let connectionId = sseConnectionsBySession.removeValue(forKey: sessionId), + let context = connections[connectionId] else { + return + } + + if reason == .idleTimeout { + await context.writeRaw(Data("\u{003A} idle-timeout\n\n".utf8)) + } + await context.cancel() + connections.removeValue(forKey: connectionId) + } + + private func handleNewConnection(_ connection: NWConnection) async { + let connectionId = UUID() + let context = HttpConnectionContext(id: connectionId, connection: connection) + connections[connectionId] = context + await context.start { [weak self] data in + guard let self else { return } + await self.handleReceivedData(connectionId: connectionId, data: data) + } onClosed: { [weak self] in + guard let self else { return } + await self.removeConnection(connectionId: connectionId) + } + } + + private func removeConnection(connectionId: UUID) async { + connections.removeValue(forKey: connectionId) + let pairs = sseConnectionsBySession.filter { $0.value == connectionId } + for (sessionId, _) in pairs { + sseConnectionsBySession.removeValue(forKey: sessionId) + } + } + + private func handleReceivedData(connectionId: UUID, data: Data) async { + guard let context = connections[connectionId] else { return } + + if data.count > configuration.limits.maxRequestBodyBytes + configuration.limits.maxHeaderBytes { + await respondTopLevel(context: context, error: .payloadTooLarge(), requestId: nil) + return + } + + let parseResult: HttpRequestParseResult + do { + parseResult = try HttpRequestParser.parse(data) + } catch HttpRequestParseError.bodyTooLarge { + await respondTopLevel(context: context, error: .payloadTooLarge(), requestId: nil) + return + } catch HttpRequestParseError.headerTooLarge { + await respondTopLevel(context: context, error: .payloadTooLarge(), requestId: nil) + return + } catch { + await respondTopLevel( + context: context, + error: .invalidRequest(detail: "Malformed HTTP"), + requestId: nil + ) + return + } + + switch parseResult { + case .incomplete: + return + case .complete(let head, let body, _): + await context.markRequestComplete() + await dispatch(head: head, body: body, context: context) + } + } + + private func dispatch(head: HttpRequestHead, body: Data, context: HttpConnectionContext) async { + let clientAddress: MCPClientAddress = await context.clientAddress() + let now = await clock.now() + + switch head.method { + case .options: + await context.writeOptions204() + await context.cancel() + return + case .get: + await handleGetMcp(head: head, body: body, context: context, clientAddress: clientAddress, now: now) + case .post: + await handlePostMcp(head: head, body: body, context: context, clientAddress: clientAddress, now: now) + case .delete: + await handleDeleteMcp(head: head, context: context, clientAddress: clientAddress) + default: + await respondTopLevel( + context: context, + error: MCPProtocolError( + code: JsonRpcErrorCode.methodNotFound, + message: "Method not allowed", + httpStatus: .methodNotAllowed + ), + requestId: nil + ) + } + } + + private func handleGetMcp( + head: HttpRequestHead, + body: Data, + context: HttpConnectionContext, + clientAddress: MCPClientAddress, + now: Date + ) async { + guard pathMatchesMcp(head.path) else { + await respondTopLevel( + context: context, + error: MCPProtocolError( + code: JsonRpcErrorCode.methodNotFound, + message: "Method not found", + httpStatus: .notFound + ), + requestId: nil + ) + return + } + + let authResult = await authenticate(headers: head.headers, clientAddress: clientAddress) + guard case .allow(let principal) = authResult else { + if case .deny(let error) = authResult { + await respondTopLevel(context: context, error: error, requestId: nil) + } + return + } + + guard let sessionIdRaw = head.headers.value(for: "Mcp-Session-Id") else { + await respondTopLevel(context: context, error: .missingSessionId(), requestId: nil) + return + } + let sessionId = MCPSessionId(sessionIdRaw) + guard await sessionStore.session(id: sessionId) != nil else { + await respondTopLevel(context: context, error: .sessionNotFound(), requestId: nil) + return + } + + await sessionStore.touch(id: sessionId) + + _ = head.headers.value(for: "Last-Event-ID") + let mcpProtocolVersion = head.headers.value(for: "mcp-protocol-version") + + let sink = TransportResponderSink(transport: self, context: context) + let responder = MCPExchangeResponder(sink: sink, requestId: nil) + + let placeholderRequest = JsonRpcRequest(id: .null, method: "$/sse-stream", params: nil) + let exchangeContext = MCPInboundContext( + sessionId: sessionId, + principal: principal, + clientAddress: clientAddress, + receivedAt: now, + mcpProtocolVersion: mcpProtocolVersion + ) + let exchange = MCPInboundExchange( + message: .request(placeholderRequest), + context: exchangeContext, + responder: responder + ) + exchangesContinuation?.yield(exchange) + } + + private func handlePostMcp( + head: HttpRequestHead, + body: Data, + context: HttpConnectionContext, + clientAddress: MCPClientAddress, + now: Date + ) async { + guard pathMatchesMcp(head.path) else { + await respondTopLevel( + context: context, + error: MCPProtocolError( + code: JsonRpcErrorCode.methodNotFound, + message: "Method not found", + httpStatus: .notFound + ), + requestId: nil + ) + return + } + + if body.count > configuration.limits.maxRequestBodyBytes { + await respondTopLevel(context: context, error: .payloadTooLarge(), requestId: nil) + return + } + + let authResult = await authenticate(headers: head.headers, clientAddress: clientAddress) + guard case .allow(let principal) = authResult else { + if case .deny(let error) = authResult { + await respondTopLevel(context: context, error: error, requestId: nil) + } + return + } + + let message: JsonRpcMessage + do { + message = try JsonRpcCodec.decode(body) + } catch { + await respondTopLevel( + context: context, + error: .parseError(detail: String(describing: error)), + requestId: nil + ) + return + } + + let requestId = extractRequestId(from: message) + let methodName = extractMethod(from: message) + let mcpProtocolVersion = head.headers.value(for: "mcp-protocol-version") + + let sessionId: MCPSessionId? + if methodName == "initialize" { + do { + let session = try await sessionStore.create() + sessionId = session.id + } catch { + await respondTopLevel( + context: context, + error: .serviceUnavailable(), + requestId: requestId + ) + return + } + } else { + guard let raw = head.headers.value(for: "Mcp-Session-Id") else { + await respondTopLevel(context: context, error: .missingSessionId(), requestId: requestId) + return + } + let candidate = MCPSessionId(raw) + guard await sessionStore.session(id: candidate) != nil else { + await respondTopLevel(context: context, error: .sessionNotFound(), requestId: requestId) + return + } + sessionId = candidate + await sessionStore.touch(id: candidate) + } + + let sink = TransportResponderSink(transport: self, context: context) + let responder = MCPExchangeResponder(sink: sink, requestId: requestId) + + let exchangeContext = MCPInboundContext( + sessionId: sessionId, + principal: principal, + clientAddress: clientAddress, + receivedAt: now, + mcpProtocolVersion: mcpProtocolVersion + ) + let exchange = MCPInboundExchange( + message: message, + context: exchangeContext, + responder: responder + ) + exchangesContinuation?.yield(exchange) + } + + private func handleDeleteMcp( + head: HttpRequestHead, + context: HttpConnectionContext, + clientAddress: MCPClientAddress + ) async { + guard pathMatchesMcp(head.path) else { + await respondTopLevel( + context: context, + error: MCPProtocolError( + code: JsonRpcErrorCode.methodNotFound, + message: "Method not found", + httpStatus: .notFound + ), + requestId: nil + ) + return + } + + let authResult = await authenticate(headers: head.headers, clientAddress: clientAddress) + guard case .allow = authResult else { + if case .deny(let error) = authResult { + await respondTopLevel(context: context, error: error, requestId: nil) + } + return + } + + guard let raw = head.headers.value(for: "Mcp-Session-Id") else { + await respondTopLevel(context: context, error: .missingSessionId(), requestId: nil) + return + } + + let sessionId = MCPSessionId(raw) + guard await sessionStore.session(id: sessionId) != nil else { + await respondTopLevel(context: context, error: .sessionNotFound(), requestId: nil) + return + } + + await sessionStore.terminate(id: sessionId, reason: .clientRequested) + await context.writeNoContent() + await context.cancel() + } + + private func authenticate( + headers: HttpHeaders, + clientAddress: MCPClientAddress + ) async -> AuthResult { + let authHeader = headers.value(for: "Authorization") + let decision = await authenticator.authenticate( + authorizationHeader: authHeader, + clientAddress: clientAddress + ) + switch decision { + case .allow(let principal): + return .allow(principal) + case .deny(let reason): + let mcpError = mapDenialToProtocolError(reason) + return .deny(mcpError) + } + } + + private func mapDenialToProtocolError(_ reason: MCPAuthDenialReason) -> MCPProtocolError { + switch reason.httpStatus { + case 401: + if let challenge = reason.challenge { + if challenge.contains("invalid_token") { + if challenge.contains("token_expired") || challenge.contains("token expired") { + return .tokenExpired() + } + return .tokenInvalid() + } + return .unauthenticated(challenge: challenge) + } + return .unauthenticated() + case 403: + return .forbidden(reason: reason.logMessage) + case 429: + return .rateLimited() + default: + return MCPProtocolError( + code: JsonRpcErrorCode.serverError, + message: reason.logMessage, + httpStatus: HttpStatus(code: reason.httpStatus, reasonPhrase: "Error"), + extraHeaders: reason.challenge.map { [("WWW-Authenticate", $0)] } ?? [] + ) + } + } + + private func respondTopLevel( + context: HttpConnectionContext, + error: MCPProtocolError, + requestId: JsonRpcId? + ) async { + let envelope = error.toJsonRpcErrorResponse(id: requestId) + let data = (try? JSONEncoder().encode(envelope)) ?? Data() + await context.writeJsonResponse( + data: data, + status: error.httpStatus, + sessionId: nil, + extraHeaders: error.extraHeaders + ) + await context.cancel() + } + + private func pathMatchesMcp(_ path: String) -> Bool { + let trimmed = stripQueryString(path) + return trimmed == "/mcp" || trimmed == "/mcp/" + } + + private func stripQueryString(_ path: String) -> String { + if let questionIndex = path.firstIndex(of: "?") { + return String(path[path.startIndex.. JsonRpcId? { + switch message { + case .request(let request): + return request.id + case .successResponse(let response): + return response.id + case .errorResponse(let response): + return response.id + case .notification: + return nil + } + } + + private func extractMethod(from message: JsonRpcMessage) -> String? { + switch message { + case .request(let request): + return request.method + case .notification(let notification): + return notification.method + case .successResponse, .errorResponse: + return nil + } + } + + fileprivate func registerSseConnection(connectionId: UUID, sessionId: MCPSessionId) { + if let previous = sseConnectionsBySession[sessionId], previous != connectionId, + let oldContext = connections[previous] { + Task { await oldContext.cancel() } + connections.removeValue(forKey: previous) + } + sseConnectionsBySession[sessionId] = connectionId + } + + private enum AuthResult { + case allow(MCPPrincipal) + case deny(MCPProtocolError) + } +} + +actor HttpConnectionContext { + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.HttpServer") + + nonisolated let id: UUID + private let connection: NWConnection + private var receiveBuffer = Data() + private var requestComplete = false + private var cancelled = false + private var sseActive = false + + init(id: UUID, connection: NWConnection) { + self.id = id + self.connection = connection + } + + func start( + onData: @escaping @Sendable (Data) async -> Void, + onClosed: @escaping @Sendable () async -> Void + ) { + let nwConnection = connection + nwConnection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + Task { await self.beginReading(onData: onData, onClosed: onClosed) } + case .failed: + Task { await self.handleClosed(onClosed: onClosed) } + case .cancelled: + Task { await self.handleClosed(onClosed: onClosed) } + default: + break + } + } + nwConnection.start(queue: .global(qos: .userInitiated)) + } + + private func beginReading( + onData: @escaping @Sendable (Data) async -> Void, + onClosed: @escaping @Sendable () async -> Void + ) { + scheduleReceive(onData: onData, onClosed: onClosed) + } + + private func scheduleReceive( + onData: @escaping @Sendable (Data) async -> Void, + onClosed: @escaping @Sendable () async -> Void + ) { + if cancelled || requestComplete { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] content, _, isComplete, error in + guard let self else { return } + Task { + await self.handleReceive( + content: content, + isComplete: isComplete, + error: error, + onData: onData, + onClosed: onClosed + ) + } + } + } + + private func handleReceive( + content: Data?, + isComplete: Bool, + error: NWError?, + onData: @escaping @Sendable (Data) async -> Void, + onClosed: @escaping @Sendable () async -> Void + ) async { + if let error { + Self.logger.debug("Receive error: \(error.localizedDescription, privacy: .public)") + cancel() + await onClosed() + return + } + + if let content { + receiveBuffer.append(content) + await onData(receiveBuffer) + } + + if isComplete { + cancel() + await onClosed() + return + } + + if !requestComplete, !cancelled { + scheduleReceive(onData: onData, onClosed: onClosed) + } + } + + private func handleClosed(onClosed: @escaping @Sendable () async -> Void) async { + if !cancelled { + cancelled = true + } + await onClosed() + } + + func markRequestComplete() { + requestComplete = true + } + + func clientAddress() -> MCPClientAddress { + guard let endpoint = connection.currentPath?.remoteEndpoint, + case .hostPort(let host, _) = endpoint else { + return .loopback + } + let hostString = "\(host)" + if hostString == "127.0.0.1" || hostString == "::1" || hostString.lowercased() == "localhost" { + return .loopback + } + return .remote(hostString) + } + + func writeJsonResponse( + data: Data, + status: HttpStatus, + sessionId: MCPSessionId?, + extraHeaders: [(String, String)] + ) { + if cancelled { return } + var headers: [(String, String)] = [ + ("Content-Type", "application/json"), + ("Connection", "close") + ] + if let sessionId { + headers.append(("Mcp-Session-Id", sessionId.rawValue)) + } + headers.append(contentsOf: extraHeaders) + headers.append(contentsOf: MCPCorsHeaders.standard) + let head = HttpResponseHead(status: status, headers: HttpHeaders(headers)) + let payload = HttpResponseEncoder.encode(head, body: data) + send(payload) + } + + func writeOptions204() { + if cancelled { return } + var headers: [(String, String)] = [("Connection", "close")] + headers.append(contentsOf: MCPCorsHeaders.standard) + let head = HttpResponseHead(status: .noContent, headers: HttpHeaders(headers)) + let payload = HttpResponseEncoder.encode(head, body: nil) + send(payload) + } + + func writeNoContent() { + if cancelled { return } + var headers: [(String, String)] = [("Connection", "close")] + headers.append(contentsOf: MCPCorsHeaders.standard) + let head = HttpResponseHead(status: .noContent, headers: HttpHeaders(headers)) + let payload = HttpResponseEncoder.encode(head, body: nil) + send(payload) + } + + func writeAccepted() { + if cancelled { return } + var headers: [(String, String)] = [("Connection", "close")] + headers.append(contentsOf: MCPCorsHeaders.standard) + let head = HttpResponseHead(status: .accepted, headers: HttpHeaders(headers)) + let payload = HttpResponseEncoder.encode(head, body: nil) + send(payload) + } + + func writeSseStreamHeaders(sessionId: MCPSessionId) { + if cancelled { return } + sseActive = true + var headers: [(String, String)] = [ + ("Content-Type", "text/event-stream"), + ("Cache-Control", "no-cache"), + ("Connection", "keep-alive"), + ("Mcp-Session-Id", sessionId.rawValue) + ] + headers.append(contentsOf: MCPCorsHeaders.standard) + let head = HttpResponseHead(status: .ok, headers: HttpHeaders(headers)) + let payload = HttpResponseEncoder.encode(head, body: nil) + send(payload) + } + + func writeSseFrame(_ frame: SseFrame) { + if cancelled { return } + let data = SseEncoder.encode(frame) + send(data) + } + + func writeRaw(_ data: Data) { + if cancelled { return } + send(data) + } + + func cancel() { + if cancelled { return } + cancelled = true + connection.cancel() + } + + func isSseActive() -> Bool { + sseActive + } + + private func send(_ data: Data) { + connection.send(content: data, completion: .contentProcessed { error in + if let error { + Self.logger.debug("Send error: \(error.localizedDescription, privacy: .public)") + } + }) + } +} + +struct TransportResponderSink: MCPResponderSink { + let transport: MCPHttpServerTransport + let context: HttpConnectionContext + + func writeJson(_ data: Data, status: HttpStatus, sessionId: MCPSessionId?, extraHeaders: [(String, String)]) async { + await context.writeJsonResponse( + data: data, + status: status, + sessionId: sessionId, + extraHeaders: extraHeaders + ) + } + + func writeAccepted() async { + await context.writeAccepted() + } + + func writeSseStreamHeaders(sessionId: MCPSessionId) async { + await context.writeSseStreamHeaders(sessionId: sessionId) + } + + func writeSseFrame(_ frame: SseFrame) async { + await context.writeSseFrame(frame) + } + + func closeConnection() async { + await context.cancel() + } + + func registerSseConnection(sessionId: MCPSessionId) async { + await transport.registerSseConnection(connectionId: context.id, sessionId: sessionId) + } +} diff --git a/TablePro/Core/MCP/Transport/MCPInboundExchange.swift b/TablePro/Core/MCP/Transport/MCPInboundExchange.swift new file mode 100644 index 000000000..b284a651e --- /dev/null +++ b/TablePro/Core/MCP/Transport/MCPInboundExchange.swift @@ -0,0 +1,146 @@ +import Foundation +import Network +import os + +public struct MCPInboundContext: Sendable { + public let sessionId: MCPSessionId? + public let principal: MCPPrincipal? + public let clientAddress: MCPClientAddress + public let receivedAt: Date + public let mcpProtocolVersion: String? + + public init( + sessionId: MCPSessionId?, + principal: MCPPrincipal?, + clientAddress: MCPClientAddress, + receivedAt: Date, + mcpProtocolVersion: String? + ) { + self.sessionId = sessionId + self.principal = principal + self.clientAddress = clientAddress + self.receivedAt = receivedAt + self.mcpProtocolVersion = mcpProtocolVersion + } +} + +public struct MCPInboundExchange: Sendable { + public let message: JsonRpcMessage + public let context: MCPInboundContext + public let responder: MCPExchangeResponder + + public init( + message: JsonRpcMessage, + context: MCPInboundContext, + responder: MCPExchangeResponder + ) { + self.message = message + self.context = context + self.responder = responder + } +} + +public protocol MCPResponderSink: Sendable { + func writeJson(_ data: Data, status: HttpStatus, sessionId: MCPSessionId?, extraHeaders: [(String, String)]) async + func writeAccepted() async + func writeSseStreamHeaders(sessionId: MCPSessionId) async + func writeSseFrame(_ frame: SseFrame) async + func closeConnection() async + func registerSseConnection(sessionId: MCPSessionId) async +} + +public actor MCPExchangeResponder { + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.HttpServer") + + private let sink: MCPResponderSink + private var completed: Bool = false + private let requestId: JsonRpcId? + + public init(sink: MCPResponderSink, requestId: JsonRpcId?) { + self.sink = sink + self.requestId = requestId + } + + public func respond(_ message: JsonRpcMessage, sessionId: MCPSessionId?) async { + guard !completed else { + Self.logger.warning("Responder.respond called after completion; ignoring") + return + } + completed = true + + let body: Data + do { + body = try JsonRpcCodec.encode(message) + } catch { + let fallback = MCPProtocolError.internalError(detail: "encode failed").toJsonRpcErrorResponse(id: requestId) + body = (try? JSONEncoder().encode(fallback)) ?? Data() + } + + await sink.writeJson(body, status: .ok, sessionId: sessionId, extraHeaders: []) + await sink.closeConnection() + } + + public func respondError(_ error: MCPProtocolError, requestId responseId: JsonRpcId?) async { + guard !completed else { + Self.logger.warning("Responder.respondError called after completion; ignoring") + return + } + completed = true + + let envelope = error.toJsonRpcErrorResponse(id: responseId ?? requestId) + let data = (try? JSONEncoder().encode(envelope)) ?? Data() + await sink.writeJson(data, status: error.httpStatus, sessionId: nil, extraHeaders: error.extraHeaders) + await sink.closeConnection() + } + + public func respondSseStream( + initialMessage: JsonRpcMessage?, + sessionId: MCPSessionId, + additional: AsyncStream + ) async { + guard !completed else { + Self.logger.warning("Responder.respondSseStream called after completion; ignoring") + return + } + completed = true + + await sink.writeSseStreamHeaders(sessionId: sessionId) + await sink.registerSseConnection(sessionId: sessionId) + + if let initialMessage { + if let payload = try? JsonRpcCodec.encode(initialMessage), + let text = String(data: payload, encoding: .utf8) { + await sink.writeSseFrame(SseFrame(data: text)) + } + } + + for await message in additional { + guard let payload = try? JsonRpcCodec.encode(message), + let text = String(data: payload, encoding: .utf8) else { continue } + await sink.writeSseFrame(SseFrame(data: text)) + } + } + + public func acknowledgeAccepted() async { + guard !completed else { + Self.logger.warning("Responder.acknowledgeAccepted called after completion; ignoring") + return + } + completed = true + await sink.writeAccepted() + await sink.closeConnection() + } + + public func reject(_ error: MCPProtocolError) async { + guard !completed else { + Self.logger.warning("Responder.reject called after completion; ignoring") + return + } + completed = true + + let envelope = error.toJsonRpcErrorResponse(id: requestId) + let data = (try? JSONEncoder().encode(envelope)) ?? Data() + await sink.writeJson(data, status: error.httpStatus, sessionId: nil, extraHeaders: error.extraHeaders) + await sink.closeConnection() + } +} diff --git a/TablePro/Core/MCP/Transport/MCPMessageTransport.swift b/TablePro/Core/MCP/Transport/MCPMessageTransport.swift new file mode 100644 index 000000000..64d4fc870 --- /dev/null +++ b/TablePro/Core/MCP/Transport/MCPMessageTransport.swift @@ -0,0 +1,18 @@ +import Foundation + +public protocol MCPMessageTransport: AnyObject, Sendable { + var inbound: AsyncThrowingStream { get } + func send(_ message: JsonRpcMessage) async throws + func close() async +} + +public enum MCPTransportError: Error, Sendable, Equatable { + case closed + case malformedFrame(detail: String) + case writeFailed(detail: String) + case readFailed(detail: String) + case invalidEndpoint + case authentication(httpStatus: Int, message: String) + case sessionExpired + case timeout +} diff --git a/TablePro/Core/MCP/Transport/MCPProtocolError.swift b/TablePro/Core/MCP/Transport/MCPProtocolError.swift new file mode 100644 index 000000000..18e4a6c37 --- /dev/null +++ b/TablePro/Core/MCP/Transport/MCPProtocolError.swift @@ -0,0 +1,158 @@ +import Foundation + +public struct MCPProtocolError: Error, Sendable, Equatable { + public let code: Int + public let message: String + public let httpStatus: HttpStatus + public let extraHeaders: [(String, String)] + public let data: JsonValue? + + public init( + code: Int, + message: String, + httpStatus: HttpStatus, + extraHeaders: [(String, String)] = [], + data: JsonValue? = nil + ) { + self.code = code + self.message = message + self.httpStatus = httpStatus + self.extraHeaders = extraHeaders + self.data = data + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.code == rhs.code && lhs.message == rhs.message + } +} + +public extension MCPProtocolError { + static func sessionNotFound(message: String = "Session not found") -> Self { + Self(code: JsonRpcErrorCode.sessionNotFound, message: message, httpStatus: .notFound) + } + + static func missingSessionId(message: String = "Missing Mcp-Session-Id header") -> Self { + Self(code: JsonRpcErrorCode.invalidRequest, message: message, httpStatus: .badRequest) + } + + static func parseError(detail: String) -> Self { + Self( + code: JsonRpcErrorCode.parseError, + message: "Parse error: \(detail)", + httpStatus: .badRequest + ) + } + + static func invalidRequest(detail: String) -> Self { + Self( + code: JsonRpcErrorCode.invalidRequest, + message: "Invalid request: \(detail)", + httpStatus: .badRequest + ) + } + + static func methodNotFound(method: String) -> Self { + Self( + code: JsonRpcErrorCode.methodNotFound, + message: "Method not found: \(method)", + httpStatus: .ok + ) + } + + static func invalidParams(detail: String) -> Self { + Self( + code: JsonRpcErrorCode.invalidParams, + message: "Invalid params: \(detail)", + httpStatus: .ok + ) + } + + static func internalError(detail: String) -> Self { + Self( + code: JsonRpcErrorCode.internalError, + message: "Internal error: \(detail)", + httpStatus: .internalServerError + ) + } + + static func unauthenticated(challenge: String = "Bearer realm=\"TablePro\"") -> Self { + Self( + code: JsonRpcErrorCode.sessionNotFound, + message: "Unauthenticated", + httpStatus: .unauthorized, + extraHeaders: [("WWW-Authenticate", challenge)] + ) + } + + static func tokenInvalid() -> Self { + Self( + code: JsonRpcErrorCode.forbidden, + message: "Token invalid", + httpStatus: .unauthorized, + extraHeaders: [("WWW-Authenticate", "Bearer error=\"invalid_token\"")] + ) + } + + static func tokenExpired() -> Self { + Self( + code: JsonRpcErrorCode.expired, + message: "Token expired", + httpStatus: .unauthorized, + extraHeaders: [("WWW-Authenticate", "Bearer error=\"invalid_token\", error_description=\"token expired\"")] + ) + } + + static func forbidden(reason: String) -> Self { + Self( + code: JsonRpcErrorCode.forbidden, + message: "Forbidden: \(reason)", + httpStatus: .forbidden + ) + } + + static func rateLimited() -> Self { + Self( + code: JsonRpcErrorCode.serverError, + message: "Rate limited", + httpStatus: .tooManyRequests + ) + } + + static func payloadTooLarge() -> Self { + Self( + code: JsonRpcErrorCode.tooLarge, + message: "Payload too large", + httpStatus: .payloadTooLarge + ) + } + + static func notAcceptable() -> Self { + Self( + code: JsonRpcErrorCode.invalidRequest, + message: "Not acceptable", + httpStatus: .notAcceptable + ) + } + + static func unsupportedMediaType() -> Self { + Self( + code: JsonRpcErrorCode.invalidRequest, + message: "Unsupported media type", + httpStatus: .unsupportedMediaType + ) + } + + static func serviceUnavailable() -> Self { + Self( + code: JsonRpcErrorCode.serverError, + message: "Service unavailable", + httpStatus: .serviceUnavailable + ) + } +} + +public extension MCPProtocolError { + func toJsonRpcErrorResponse(id: JsonRpcId?) -> JsonRpcErrorResponse { + JsonRpcErrorResponse(id: id, error: JsonRpcError(code: code, message: message, data: data)) + } +} diff --git a/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift b/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift new file mode 100644 index 000000000..2b301f135 --- /dev/null +++ b/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift @@ -0,0 +1,154 @@ +import Foundation + +public final class MCPStdioMessageTransport: MCPMessageTransport, @unchecked Sendable { + public let inbound: AsyncThrowingStream + + private let continuation: AsyncThrowingStream.Continuation + private let writer: StdioWriter + private let errorLogger: MCPBridgeLogger? + private let stateLock = NSLock() + private var readerTask: Task? + private var isClosed = false + + public init( + stdin: FileHandle = .standardInput, + stdout: FileHandle = .standardOutput, + errorLogger: MCPBridgeLogger? = nil + ) { + self.errorLogger = errorLogger + writer = StdioWriter(handle: stdout) + + var capturedContinuation: AsyncThrowingStream.Continuation! + inbound = AsyncThrowingStream { continuation in + capturedContinuation = continuation + } + continuation = capturedContinuation + + startReader(stdin: stdin) + } + + public func send(_ message: JsonRpcMessage) async throws { + stateLock.lock() + let closed = isClosed + stateLock.unlock() + if closed { + throw MCPTransportError.closed + } + + let line: Data + do { + line = try JsonRpcCodec.encodeLine(message) + } catch { + throw MCPTransportError.writeFailed(detail: String(describing: error)) + } + + do { + try await writer.write(line) + } catch { + throw MCPTransportError.writeFailed(detail: String(describing: error)) + } + } + + public func close() async { + stateLock.lock() + if isClosed { + stateLock.unlock() + return + } + isClosed = true + let task = readerTask + readerTask = nil + stateLock.unlock() + + task?.cancel() + continuation.finish() + } + + private func startReader(stdin: FileHandle) { + let continuation = continuation + let logger = errorLogger + let task = Task.detached(priority: .userInitiated) { [weak self] in + await Self.readLoop(stdin: stdin, continuation: continuation, logger: logger) + await self?.finishStream() + } + + stateLock.lock() + readerTask = task + stateLock.unlock() + } + + private func finishStream() async { + stateLock.lock() + if isClosed { + stateLock.unlock() + return + } + isClosed = true + readerTask = nil + stateLock.unlock() + continuation.finish() + } + + private static func readLoop( + stdin: FileHandle, + continuation: AsyncThrowingStream.Continuation, + logger: MCPBridgeLogger? + ) async { + var buffer = Data() + do { + for try await byte in stdin.bytes { + if Task.isCancelled { + return + } + if byte == 0x0A { + processLine(buffer, continuation: continuation, logger: logger) + buffer.removeAll(keepingCapacity: true) + continue + } + buffer.append(byte) + } + } catch { + logger?.log(.error, "stdio read failed: \(error)") + continuation.finish(throwing: MCPTransportError.readFailed(detail: String(describing: error))) + return + } + + if !buffer.isEmpty { + processLine(buffer, continuation: continuation, logger: logger) + } + } + + private static func processLine( + _ raw: Data, + continuation: AsyncThrowingStream.Continuation, + logger: MCPBridgeLogger? + ) { + var trimmed = raw + if trimmed.last == 0x0D { + trimmed.removeLast() + } + if trimmed.isEmpty { + return + } + + do { + let message = try JsonRpcCodec.decode(trimmed) + continuation.yield(message) + } catch { + logger?.log(.warning, "stdio: skipping malformed JSON-RPC line: \(error)") + } + } +} + +private actor StdioWriter { + private let handle: FileHandle + + init(handle: FileHandle) { + self.handle = handle + } + + func write(_ data: Data) throws { + try handle.write(contentsOf: data) + try? handle.synchronize() + } +} diff --git a/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift b/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift new file mode 100644 index 000000000..be67cdb77 --- /dev/null +++ b/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift @@ -0,0 +1,447 @@ +import CryptoKit +import Foundation +import Security + +public struct MCPStreamableHttpClientConfiguration: Sendable { + public let endpoint: URL + public let bearerToken: String + public let tlsCertFingerprint: String? + public let requestTimeout: Duration + public let serverInitiatedStream: Bool + + public init( + endpoint: URL, + bearerToken: String, + tlsCertFingerprint: String? = nil, + requestTimeout: Duration = .seconds(60), + serverInitiatedStream: Bool = false + ) { + self.endpoint = endpoint + self.bearerToken = bearerToken + self.tlsCertFingerprint = tlsCertFingerprint + self.requestTimeout = requestTimeout + self.serverInitiatedStream = serverInitiatedStream + } +} + +public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unchecked Sendable { + public let inbound: AsyncThrowingStream + + private let continuation: AsyncThrowingStream.Continuation + private let configuration: MCPStreamableHttpClientConfiguration + private let urlSession: URLSession + private let errorLogger: MCPBridgeLogger? + private let state: ClientState + private let writer: HttpWriter + + public init( + configuration: MCPStreamableHttpClientConfiguration, + urlSession: URLSession? = nil, + errorLogger: MCPBridgeLogger? = nil + ) { + self.configuration = configuration + self.errorLogger = errorLogger + state = ClientState() + + var capturedContinuation: AsyncThrowingStream.Continuation! + inbound = AsyncThrowingStream { continuation in + capturedContinuation = continuation + } + continuation = capturedContinuation + + if let urlSession { + self.urlSession = urlSession + } else { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = TimeInterval(configuration.requestTimeout.components.seconds) + config.timeoutIntervalForResource = TimeInterval(configuration.requestTimeout.components.seconds) + if let fingerprint = configuration.tlsCertFingerprint { + let delegate = CertificatePinningDelegate(expectedFingerprint: fingerprint, errorLogger: errorLogger) + self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + } else { + self.urlSession = URLSession(configuration: config) + } + } + + writer = HttpWriter() + } + + public func send(_ message: JsonRpcMessage) async throws { + if await state.isClosed { + throw MCPTransportError.closed + } + + let requestId = Self.requestId(of: message) + let body: Data + do { + body = try JsonRpcCodec.encode(message) + } catch { + throw MCPTransportError.writeFailed(detail: String(describing: error)) + } + + let task: Task = Task { [weak self] in + guard let self else { return } + await self.dispatch(body: body, requestId: requestId) + } + await state.trackTask(task) + } + + public func openSseStream() async throws { + if await state.isClosed { + throw MCPTransportError.closed + } + if await state.serverInitiatedStreamOpen { + return + } + await state.markServerInitiatedStreamOpen(true) + + let task: Task = Task { [weak self] in + guard let self else { return } + await self.runServerInitiatedStream() + } + await state.trackTask(task) + } + + public func close() async { + if await state.isClosed { + return + } + await state.setClosed() + let tasks = await state.takeTasks() + for task in tasks { + task.cancel() + } + urlSession.invalidateAndCancel() + continuation.finish() + } + + private func dispatch(body: Data, requestId: JsonRpcId?) async { + do { + try await writer.serialize { + try await self.performRequest(body: body, requestId: requestId) + } + } catch { + await handleSendError(error: error, requestId: requestId) + } + } + + private func performRequest(body: Data, requestId: JsonRpcId?) async throws { + var request = URLRequest(url: configuration.endpoint) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(configuration.bearerToken)", forHTTPHeaderField: "Authorization") + if let sessionId = await state.sessionId { + request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") + } + + let (bytes, response) = try await urlSession.bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw MCPTransportError.readFailed(detail: "non-HTTP response") + } + + await captureSessionIdIfPresent(from: httpResponse) + + let status = httpResponse.statusCode + let contentType = headerValue(httpResponse, name: "Content-Type")?.lowercased() ?? "" + + if (200..<300).contains(status) { + if contentType.contains("text/event-stream") { + try await consumeSseBytes(bytes) + return + } + if contentType.contains("application/json") { + let data = try await collectBytes(bytes) + if data.isEmpty { + return + } + pushJsonBody(data, fallbackId: requestId) + return + } + let data = try await collectBytes(bytes) + if data.isEmpty { + return + } + pushJsonBody(data, fallbackId: requestId) + return + } + + let data = try await collectBytes(bytes) + await handleNonSuccessResponse( + status: status, + headers: httpResponse, + body: data, + requestId: requestId + ) + } + + private func runServerInitiatedStream() async { + do { + var request = URLRequest(url: configuration.endpoint) + request.httpMethod = "GET" + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(configuration.bearerToken)", forHTTPHeaderField: "Authorization") + if let sessionId = await state.sessionId { + request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") + } + + let (bytes, response) = try await urlSession.bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + errorLogger?.log(.warning, "server-initiated stream: non-HTTP response") + return + } + await captureSessionIdIfPresent(from: httpResponse) + let status = httpResponse.statusCode + guard (200..<300).contains(status) else { + let body = try await collectBytes(bytes) + await handleNonSuccessResponse( + status: status, + headers: httpResponse, + body: body, + requestId: nil + ) + return + } + try await consumeSseBytes(bytes) + } catch { + if Task.isCancelled { + return + } + errorLogger?.log(.warning, "server-initiated stream ended: \(error)") + } + } + + private func consumeSseBytes(_ bytes: URLSession.AsyncBytes) async throws { + let decoder = SseDecoder() + var chunk = Data() + for try await byte in bytes { + if Task.isCancelled { + return + } + chunk.append(byte) + if byte == 0x0A { + let frames = await decoder.feed(chunk) + chunk.removeAll(keepingCapacity: true) + for frame in frames { + pushSseFrame(frame) + } + } + } + if !chunk.isEmpty { + let frames = await decoder.feed(chunk) + for frame in frames { + pushSseFrame(frame) + } + } + } + + private func collectBytes(_ bytes: URLSession.AsyncBytes) async throws -> Data { + var data = Data() + for try await byte in bytes { + if Task.isCancelled { + return data + } + data.append(byte) + } + return data + } + + private func pushSseFrame(_ frame: SseFrame) { + guard let payload = frame.data.data(using: .utf8) else { return } + if payload.isEmpty { + return + } + do { + let message = try JsonRpcCodec.decode(payload) + continuation.yield(message) + } catch { + errorLogger?.log(.warning, "SSE: skipping malformed JSON-RPC frame: \(error)") + } + } + + private func pushJsonBody(_ data: Data, fallbackId: JsonRpcId?) { + do { + let message = try JsonRpcCodec.decode(data) + continuation.yield(message) + } catch { + errorLogger?.log(.warning, "HTTP: malformed JSON-RPC body: \(error)") + let synthetic = MCPProtocolError.parseError(detail: String(describing: error)) + .toJsonRpcErrorResponse(id: fallbackId) + continuation.yield(.errorResponse(synthetic)) + } + } + + private func handleNonSuccessResponse( + status: Int, + headers: HTTPURLResponse, + body: Data, + requestId: JsonRpcId? + ) async { + if requestId == nil { + errorLogger?.log(.warning, "HTTP \(status) for notification (no response will be emitted)") + return + } + + if !body.isEmpty, let parsed = try? JsonRpcCodec.decode(body) { + if case .errorResponse = parsed { + continuation.yield(parsed) + return + } + if case .successResponse = parsed { + continuation.yield(parsed) + return + } + } + + let challenge = headerValue(headers, name: "WWW-Authenticate") ?? "Bearer realm=\"TablePro\"" + let protocolError = Self.protocolError(forStatus: status, body: body, challenge: challenge) + let response = protocolError.toJsonRpcErrorResponse(id: requestId) + continuation.yield(.errorResponse(response)) + } + + private func handleSendError(error: Error, requestId: JsonRpcId?) async { + if Task.isCancelled { + return + } + errorLogger?.log(.error, "HTTP send failed: \(error)") + guard let requestId else { + return + } + let protocolError = MCPProtocolError.internalError(detail: String(describing: error)) + let response = protocolError.toJsonRpcErrorResponse(id: requestId) + continuation.yield(.errorResponse(response)) + } + + private func captureSessionIdIfPresent(from response: HTTPURLResponse) async { + guard let value = headerValue(response, name: "Mcp-Session-Id") else { return } + await state.setSessionId(value) + } + + private func headerValue(_ response: HTTPURLResponse, name: String) -> String? { + let target = name.lowercased() + for (rawKey, rawValue) in response.allHeaderFields { + guard let key = rawKey as? String, + key.lowercased() == target, + let value = rawValue as? String else { continue } + return value + } + return nil + } + + private static func requestId(of message: JsonRpcMessage) -> JsonRpcId? { + switch message { + case .request(let request): + return request.id + case .notification: + return nil + case .successResponse(let response): + return response.id + case .errorResponse(let response): + return response.id + } + } + + private static func protocolError(forStatus status: Int, body: Data, challenge: String) -> MCPProtocolError { + let detail = String(data: body, encoding: .utf8) ?? "HTTP \(status)" + switch status { + case 400: + return .invalidRequest(detail: detail) + case 401: + return .unauthenticated(challenge: challenge) + case 403: + return .forbidden(reason: detail) + case 404: + return .sessionNotFound(message: detail.isEmpty ? "Session not found" : detail) + case 406: + return .notAcceptable() + case 413: + return .payloadTooLarge() + case 415: + return .unsupportedMediaType() + case 429: + return .rateLimited() + case 503: + return .serviceUnavailable() + default: + return .internalError(detail: detail) + } + } +} + +private actor ClientState { + private(set) var sessionId: String? + private(set) var isClosed = false + private(set) var serverInitiatedStreamOpen = false + private var tasks: [Task] = [] + + func setSessionId(_ id: String) { + sessionId = id + } + + func setClosed() { + isClosed = true + } + + func markServerInitiatedStreamOpen(_ value: Bool) { + serverInitiatedStreamOpen = value + } + + func trackTask(_ task: Task) { + tasks.removeAll { $0.isCancelled } + tasks.append(task) + } + + func takeTasks() -> [Task] { + let copy = tasks + tasks.removeAll() + return copy + } +} + +private actor HttpWriter { + func serialize(_ work: @Sendable () async throws -> T) async throws -> T { + try await work() + } +} + +private final class CertificatePinningDelegate: NSObject, URLSessionDelegate { + private let expectedFingerprint: String + private let errorLogger: MCPBridgeLogger? + + init(expectedFingerprint: String, errorLogger: MCPBridgeLogger?) { + self.expectedFingerprint = expectedFingerprint + self.errorLogger = errorLogger + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust else { + return (.performDefaultHandling, nil) + } + + guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], + let leaf = chain.first else { + errorLogger?.log(.error, "TLS pinning: empty cert chain") + return (.cancelAuthenticationChallenge, nil) + } + + let fingerprint = Self.sha256Fingerprint(of: leaf) + if fingerprint.caseInsensitiveCompare(expectedFingerprint) != .orderedSame { + let prefix = String(fingerprint.prefix(8)) + errorLogger?.log(.error, "TLS pinning: cert mismatch (got \(prefix)...)") + return (.cancelAuthenticationChallenge, nil) + } + return (.useCredential, URLCredential(trust: trust)) + } + + private static func sha256Fingerprint(of certificate: SecCertificate) -> String { + let data = SecCertificateCopyData(certificate) as Data + return SHA256.hash(data: data) + .map { String(format: "%02X", $0) } + .joined(separator: ":") + } +} diff --git a/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift b/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift new file mode 100644 index 000000000..629d57fab --- /dev/null +++ b/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift @@ -0,0 +1,97 @@ +import Foundation +@testable import TablePro + +actor StubAlwaysAllowAuthenticator: MCPAuthenticator { + private let principal: MCPPrincipal + + init(scopes: Set = [.toolsRead, .toolsWrite]) { + self.principal = MCPPrincipal( + tokenFingerprint: "stubtoken", + scopes: scopes, + metadata: MCPPrincipalMetadata( + label: "stub", + issuedAt: Date(timeIntervalSince1970: 1_700_000_000), + expiresAt: nil + ) + ) + } + + func authenticate( + authorizationHeader: String?, + clientAddress: MCPClientAddress + ) async -> MCPAuthDecision { + .allow(principal) + } +} + +actor StubBearerAuthenticator: MCPAuthenticator { + private let validToken: String + private let principal: MCPPrincipal + private var attemptsByAddress: [MCPClientAddress: Int] = [:] + private let maxAttempts: Int + + init(validToken: String, maxAttempts: Int = 5) { + self.validToken = validToken + self.maxAttempts = maxAttempts + self.principal = MCPPrincipal( + tokenFingerprint: "fingerprint", + scopes: [.toolsRead, .toolsWrite], + metadata: MCPPrincipalMetadata( + label: "test", + issuedAt: Date(timeIntervalSince1970: 1_700_000_000), + expiresAt: nil + ) + ) + } + + func authenticate( + authorizationHeader: String?, + clientAddress: MCPClientAddress + ) async -> MCPAuthDecision { + let attempts = attemptsByAddress[clientAddress] ?? 0 + if attempts >= maxAttempts { + return .deny(.rateLimited()) + } + + guard let raw = authorizationHeader, !raw.isEmpty else { + attemptsByAddress[clientAddress] = attempts + 1 + return .deny(.unauthenticated(reason: "missing")) + } + + let lowered = raw.lowercased() + guard lowered.hasPrefix("bearer ") else { + attemptsByAddress[clientAddress] = attempts + 1 + return .deny(.unauthenticated(reason: "bad scheme")) + } + let token = String(raw.dropFirst("bearer ".count)).trimmingCharacters(in: .whitespaces) + + if token == validToken { + attemptsByAddress[clientAddress] = 0 + return .allow(principal) + } + + attemptsByAddress[clientAddress] = attempts + 1 + return .deny(.tokenInvalid(reason: "bad token")) + } +} + +actor StubExchangeConsumer { + private var task: Task? + + func start( + transport: MCPHttpServerTransport, + responder: @escaping @Sendable (MCPInboundExchange) async -> Void + ) async { + let stream = transport.exchanges + task = Task { + for await exchange in stream { + await responder(exchange) + } + } + } + + func stop() { + task?.cancel() + task = nil + } +} diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift new file mode 100644 index 000000000..d722f9628 --- /dev/null +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerConfigurationTests.swift @@ -0,0 +1,72 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("MCP HTTP Server Configuration") +struct MCPHttpServerConfigurationTests { + @Test("Loopback factory works without TLS") + func loopbackWithoutTls() { + let config = MCPHttpServerConfiguration.loopback(port: 23_508) + #expect(config.bindAddress == .loopback) + #expect(config.port == 23_508) + #expect(config.tls == nil) + #expect(config.limits.maxRequestBodyBytes == 10 * 1_024 * 1_024) + } + + @Test("Standard limits expose 10 MiB body cap and 16 KiB header cap") + func standardLimits() { + let limits = MCPHttpServerLimits.standard + #expect(limits.maxRequestBodyBytes == 10 * 1_024 * 1_024) + #expect(limits.maxHeaderBytes == 16 * 1_024) + #expect(limits.connectionTimeout == .seconds(30)) + } + + @Test("Custom limits are preserved") + func customLimits() { + let limits = MCPHttpServerLimits( + maxRequestBodyBytes: 1_024, + maxHeaderBytes: 512, + connectionTimeout: .seconds(5) + ) + let config = MCPHttpServerConfiguration.loopback(port: 5_000, limits: limits) + #expect(config.limits.maxRequestBodyBytes == 1_024) + #expect(config.limits.maxHeaderBytes == 512) + #expect(config.limits.connectionTimeout == .seconds(5)) + } + + @Test("Loopback factory custom port is preserved") + func customPort() { + let config = MCPHttpServerConfiguration.loopback(port: 65_500) + #expect(config.port == 65_500) + } + + @Test("Transport refuses to start anyInterface bind without TLS") + func remoteRequiresTls() async { + let store = MCPSessionStore() + let authenticator = StubAlwaysAllowAuthenticator() + let unsafe = MCPHttpServerConfiguration.unsafeMake( + bindAddress: .anyInterface, + port: 0, + tls: nil, + limits: .standard + ) + let transport = MCPHttpServerTransport( + configuration: unsafe, + sessionStore: store, + authenticator: authenticator + ) + var captured: Error? + do { + try await transport.start() + } catch { + captured = error + } + #expect(captured is MCPHttpServerError) + if case .tlsRequiredForRemoteAccess = captured as? MCPHttpServerError { + #expect(true) + } else { + Issue.record("Expected tlsRequiredForRemoteAccess, got \(String(describing: captured))") + } + await transport.stop() + } +} diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift new file mode 100644 index 000000000..c007b02a6 --- /dev/null +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift @@ -0,0 +1,416 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("MCP HTTP Server Transport") +struct MCPHttpServerTransportTests { + private static let mcpVersion = "2024-11-05" + + private func makeTransport( + authenticator: any MCPAuthenticator, + clock: any MCPClock = MCPSystemClock(), + sessionPolicy: MCPSessionPolicy = MCPSessionPolicy( + idleTimeout: .seconds(900), + maxSessions: 16, + cleanupInterval: .seconds(60) + ) + ) -> (MCPHttpServerTransport, MCPSessionStore) { + let store = MCPSessionStore(policy: sessionPolicy, clock: clock) + let config = MCPHttpServerConfiguration.loopback(port: 0) + let transport = MCPHttpServerTransport( + configuration: config, + sessionStore: store, + authenticator: authenticator, + clock: clock + ) + return (transport, store) + } + + private func startedTransport( + authenticator: any MCPAuthenticator, + clock: any MCPClock = MCPSystemClock(), + sessionPolicy: MCPSessionPolicy = MCPSessionPolicy( + idleTimeout: .seconds(900), + maxSessions: 16, + cleanupInterval: .seconds(60) + ) + ) async throws -> (MCPHttpServerTransport, MCPSessionStore, UInt16) { + let (transport, store) = makeTransport( + authenticator: authenticator, + clock: clock, + sessionPolicy: sessionPolicy + ) + + let stateStream = transport.listenerState + let stateTask = Task { + for await state in stateStream { + if case .running(let port) = state { + return port + } + if case .failed = state { + return nil + } + } + return nil + } + + try await transport.start() + guard let port = await stateTask.value, port != 0 else { + await transport.stop() + throw TestError.serverDidNotStart + } + return (transport, store, port) + } + + private func makePost( + port: UInt16, + body: Data, + sessionId: String? = nil, + authorization: String? = "Bearer test-token", + contentType: String = "application/json" + ) -> URLRequest { + guard let url = URL(string: "http://127.0.0.1:\(port)/mcp") else { + fatalError("Failed to construct test URL") + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.setValue(Self.mcpVersion, forHTTPHeaderField: "mcp-protocol-version") + if let sessionId { + request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") + } + if let authorization { + request.setValue(authorization, forHTTPHeaderField: "Authorization") + } + return request + } + + private func makeOptions(port: UInt16) -> URLRequest { + guard let url = URL(string: "http://127.0.0.1:\(port)/mcp") else { + fatalError("Failed to construct test URL") + } + var request = URLRequest(url: url) + request.httpMethod = "OPTIONS" + request.setValue("Bearer test-token", forHTTPHeaderField: "Authorization") + return request + } + + private func makeRequestBody(method: String, id: Int = 1) throws -> Data { + let request = JsonRpcRequest(id: .number(Int64(id)), method: method, params: nil) + return try JsonRpcCodec.encode(.request(request)) + } + + private func parseJsonRpcError(_ data: Data) throws -> (id: JsonRpcId?, code: Int, message: String) { + let decoded = try JsonRpcCodec.decode(data) + guard case .errorResponse(let envelope) = decoded else { + throw TestError.expectedErrorEnvelope + } + return (envelope.id, envelope.error.code, envelope.error.message) + } + + private func runEchoLoop( + transport: MCPHttpServerTransport, + consumer: StubExchangeConsumer, + successResult: JsonValue = .object(["ok": .bool(true)]) + ) async { + await consumer.start(transport: transport) { exchange in + switch exchange.message { + case .request(let request): + let response = JsonRpcMessage.successResponse( + JsonRpcSuccessResponse(id: request.id, result: successResult) + ) + await exchange.responder.respond(response, sessionId: exchange.context.sessionId) + case .notification: + await exchange.responder.acknowledgeAccepted() + default: + await exchange.responder.respondError(.invalidRequest(detail: "unsupported"), requestId: nil) + } + } + } + + @Test("Initialize creates session and returns Mcp-Session-Id header") + func initializeCreatesSession() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + await runEchoLoop(transport: transport, consumer: consumer) + defer { Task { await consumer.stop() } } + + let body = try makeRequestBody(method: "initialize") + let request = makePost(port: port, body: body) + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + + #expect(httpResponse.statusCode == 200) + #expect(httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") != nil) + + let decoded = try JsonRpcCodec.decode(data) + guard case .successResponse = decoded else { + Issue.record("Expected success response, got \(decoded)") + return + } + } + + @Test("Tool call with valid session returns 200 and session header") + func toolCallWithValidSession() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + await runEchoLoop(transport: transport, consumer: consumer) + defer { Task { await consumer.stop() } } + + let initBody = try makeRequestBody(method: "initialize", id: 1) + let (_, initResponse) = try await URLSession.shared.data(for: makePost(port: port, body: initBody)) + let initHttp = try #require(initResponse as? HTTPURLResponse) + let sessionId = try #require(initHttp.value(forHTTPHeaderField: "Mcp-Session-Id")) + + let toolBody = try makeRequestBody(method: "tools/call", id: 2) + let (toolData, toolResponse) = try await URLSession.shared.data( + for: makePost(port: port, body: toolBody, sessionId: sessionId) + ) + let toolHttp = try #require(toolResponse as? HTTPURLResponse) + + #expect(toolHttp.statusCode == 200) + let decoded = try JsonRpcCodec.decode(toolData) + guard case .successResponse = decoded else { + Issue.record("Expected success response, got \(decoded)") + return + } + } + + @Test("Tool call without session id returns 400 with JSON-RPC error envelope") + func toolCallMissingSessionId() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + await runEchoLoop(transport: transport, consumer: consumer) + defer { Task { await consumer.stop() } } + + let body = try makeRequestBody(method: "tools/call", id: 7) + let (data, response) = try await URLSession.shared.data(for: makePost(port: port, body: body)) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 400) + let parsed = try parseJsonRpcError(data) + #expect(parsed.code == JsonRpcErrorCode.invalidRequest) + } + + @Test("Tool call with stale session id returns 404 with JSON-RPC error envelope") + func toolCallStaleSession() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + await runEchoLoop(transport: transport, consumer: consumer) + defer { Task { await consumer.stop() } } + + let body = try makeRequestBody(method: "tools/call", id: 8) + let (data, response) = try await URLSession.shared.data( + for: makePost(port: port, body: body, sessionId: "nonexistent-session-id") + ) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 404) + let parsed = try parseJsonRpcError(data) + #expect(parsed.code == JsonRpcErrorCode.sessionNotFound) + } + + @Test("Missing Authorization returns 401 with WWW-Authenticate") + func missingAuthorization() async throws { + let auth = StubBearerAuthenticator(validToken: "valid") + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + await runEchoLoop(transport: transport, consumer: consumer) + defer { Task { await consumer.stop() } } + + let body = try makeRequestBody(method: "initialize", id: 1) + let request = makePost(port: port, body: body, authorization: nil) + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 401) + let challenge = http.value(forHTTPHeaderField: "Www-Authenticate") ?? http.value(forHTTPHeaderField: "WWW-Authenticate") + #expect(challenge?.contains("Bearer") == true) + let parsed = try parseJsonRpcError(data) + #expect(parsed.code != 0) + } + + @Test("Bad bearer token returns 401 with JSON-RPC error envelope") + func badBearerToken() async throws { + let auth = StubBearerAuthenticator(validToken: "valid") + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + await runEchoLoop(transport: transport, consumer: consumer) + defer { Task { await consumer.stop() } } + + let body = try makeRequestBody(method: "initialize", id: 1) + let request = makePost(port: port, body: body, authorization: "Bearer wrong-token") + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 401) + let parsed = try parseJsonRpcError(data) + #expect(parsed.code != 0) + } + + @Test("Rate limit kicks in after repeated bad attempts") + func rateLimitAfterBadAttempts() async throws { + let auth = StubBearerAuthenticator(validToken: "valid", maxAttempts: 3) + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + await runEchoLoop(transport: transport, consumer: consumer) + defer { Task { await consumer.stop() } } + + let body = try makeRequestBody(method: "initialize", id: 1) + + for _ in 0..<3 { + let request = makePost(port: port, body: body, authorization: "Bearer wrong-token") + _ = try await URLSession.shared.data(for: request) + } + + let request = makePost(port: port, body: body, authorization: "Bearer wrong-token") + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 429) + let parsed = try parseJsonRpcError(data) + #expect(parsed.code != 0) + } + + @Test("Payload too large returns 413 with JSON-RPC error envelope") + func payloadTooLarge() async throws { + let auth = StubAlwaysAllowAuthenticator() + let limits = MCPHttpServerLimits( + maxRequestBodyBytes: 1_024, + maxHeaderBytes: 16 * 1_024, + connectionTimeout: .seconds(30) + ) + let store = MCPSessionStore() + let config = MCPHttpServerConfiguration.loopback(port: 0, limits: limits) + let transport = MCPHttpServerTransport( + configuration: config, + sessionStore: store, + authenticator: auth + ) + + let stateStream = transport.listenerState + let stateTask = Task { + for await state in stateStream { + if case .running(let port) = state { return port } + if case .failed = state { return nil } + } + return nil + } + try await transport.start() + let port = try #require(await stateTask.value) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + await runEchoLoop(transport: transport, consumer: consumer) + defer { Task { await consumer.stop() } } + + let bigBody = Data(repeating: 0x41, count: 2_048) + let request = makePost(port: port, body: bigBody) + let (_, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + #expect(http.statusCode == 413) + } + + @Test("Method not found at unknown path returns 404 with JSON-RPC error envelope") + func unknownPathReturns404() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + await runEchoLoop(transport: transport, consumer: consumer) + defer { Task { await consumer.stop() } } + + guard let url = URL(string: "http://127.0.0.1:\(port)/foo") else { + Issue.record("Failed to construct URL") + return + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer test", forHTTPHeaderField: "Authorization") + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 404) + let parsed = try parseJsonRpcError(data) + #expect(parsed.code == JsonRpcErrorCode.methodNotFound) + } + + @Test("OPTIONS request returns 204 with CORS headers") + func optionsReturnsNoContent() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let request = makeOptions(port: port) + let (_, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 204) + let allowOrigin = http.value(forHTTPHeaderField: "Access-Control-Allow-Origin") + #expect(allowOrigin != nil) + let allowHeaders = http.value(forHTTPHeaderField: "Access-Control-Allow-Headers") + #expect(allowHeaders?.contains("Last-Event-ID") == true) + } + + @Test("Idle session eviction terminates SSE-tracked sessions") + func idleSessionEviction() async throws { + let clock = MCPTestClock(start: Date(timeIntervalSince1970: 1_000_000)) + let auth = StubAlwaysAllowAuthenticator() + let policy = MCPSessionPolicy( + idleTimeout: .seconds(60), + maxSessions: 16, + cleanupInterval: .seconds(60) + ) + let (transport, store, port) = try await startedTransport( + authenticator: auth, + clock: clock, + sessionPolicy: policy + ) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + await runEchoLoop(transport: transport, consumer: consumer) + defer { Task { await consumer.stop() } } + + let initBody = try makeRequestBody(method: "initialize") + let (_, initResponse) = try await URLSession.shared.data(for: makePost(port: port, body: initBody)) + let initHttp = try #require(initResponse as? HTTPURLResponse) + let sessionId = try #require(initHttp.value(forHTTPHeaderField: "Mcp-Session-Id")) + + await clock.advance(by: .seconds(120)) + await store.runCleanupPass() + + let body = try makeRequestBody(method: "tools/call", id: 9) + let request = makePost(port: port, body: body, sessionId: sessionId) + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + #expect(http.statusCode == 404) + let parsed = try parseJsonRpcError(data) + #expect(parsed.code == JsonRpcErrorCode.sessionNotFound) + } +} + +private enum TestError: Error { + case serverDidNotStart + case expectedErrorEnvelope +} diff --git a/TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift b/TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift new file mode 100644 index 000000000..f3f8831ca --- /dev/null +++ b/TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift @@ -0,0 +1,126 @@ +import Foundation +@testable import TablePro +import XCTest + +final class MCPProtocolErrorTests: XCTestCase { + func testSessionNotFoundMapping() { + let error = MCPProtocolError.sessionNotFound() + XCTAssertEqual(error.code, JsonRpcErrorCode.sessionNotFound) + XCTAssertEqual(error.httpStatus, .notFound) + } + + func testMissingSessionIdMapping() { + let error = MCPProtocolError.missingSessionId() + XCTAssertEqual(error.code, JsonRpcErrorCode.invalidRequest) + XCTAssertEqual(error.httpStatus, .badRequest) + } + + func testParseErrorMapping() { + let error = MCPProtocolError.parseError(detail: "bad json") + XCTAssertEqual(error.code, JsonRpcErrorCode.parseError) + XCTAssertEqual(error.httpStatus, .badRequest) + XCTAssertTrue(error.message.contains("bad json")) + } + + func testInvalidRequestMapping() { + let error = MCPProtocolError.invalidRequest(detail: "missing method") + XCTAssertEqual(error.code, JsonRpcErrorCode.invalidRequest) + XCTAssertEqual(error.httpStatus, .badRequest) + } + + func testMethodNotFoundIsHttp200() { + let error = MCPProtocolError.methodNotFound(method: "tools/foo") + XCTAssertEqual(error.code, JsonRpcErrorCode.methodNotFound) + XCTAssertEqual(error.httpStatus, .ok) + } + + func testInvalidParamsIsHttp200() { + let error = MCPProtocolError.invalidParams(detail: "expected object") + XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + XCTAssertEqual(error.httpStatus, .ok) + } + + func testInternalErrorMapping() { + let error = MCPProtocolError.internalError(detail: "boom") + XCTAssertEqual(error.code, JsonRpcErrorCode.internalError) + XCTAssertEqual(error.httpStatus, .internalServerError) + } + + func testUnauthenticatedIncludesWwwAuthenticate() { + let error = MCPProtocolError.unauthenticated(challenge: "Bearer realm=\"x\"") + XCTAssertEqual(error.httpStatus, .unauthorized) + let header = error.extraHeaders.first { $0.0.lowercased() == "www-authenticate" } + XCTAssertNotNil(header) + XCTAssertEqual(header?.1, "Bearer realm=\"x\"") + } + + func testTokenInvalidIncludesWwwAuthenticate() { + let error = MCPProtocolError.tokenInvalid() + XCTAssertEqual(error.httpStatus, .unauthorized) + XCTAssertTrue(error.extraHeaders.contains { $0.0.lowercased() == "www-authenticate" }) + } + + func testTokenExpiredIncludesWwwAuthenticate() { + let error = MCPProtocolError.tokenExpired() + XCTAssertEqual(error.httpStatus, .unauthorized) + XCTAssertTrue(error.extraHeaders.contains { $0.0.lowercased() == "www-authenticate" }) + } + + func testForbiddenMapping() { + let error = MCPProtocolError.forbidden(reason: "policy") + XCTAssertEqual(error.code, JsonRpcErrorCode.forbidden) + XCTAssertEqual(error.httpStatus, .forbidden) + } + + func testRateLimitedMapping() { + let error = MCPProtocolError.rateLimited() + XCTAssertEqual(error.httpStatus, .tooManyRequests) + } + + func testPayloadTooLargeMapping() { + let error = MCPProtocolError.payloadTooLarge() + XCTAssertEqual(error.code, JsonRpcErrorCode.tooLarge) + XCTAssertEqual(error.httpStatus, .payloadTooLarge) + } + + func testNotAcceptableMapping() { + let error = MCPProtocolError.notAcceptable() + XCTAssertEqual(error.httpStatus, .notAcceptable) + } + + func testUnsupportedMediaTypeMapping() { + let error = MCPProtocolError.unsupportedMediaType() + XCTAssertEqual(error.httpStatus, .unsupportedMediaType) + } + + func testServiceUnavailableMapping() { + let error = MCPProtocolError.serviceUnavailable() + XCTAssertEqual(error.httpStatus, .serviceUnavailable) + } + + func testToJsonRpcErrorResponseRoundTrip() { + let protocolError = MCPProtocolError.sessionNotFound() + let response = protocolError.toJsonRpcErrorResponse(id: .number(7)) + XCTAssertEqual(response.id, .number(7)) + XCTAssertEqual(response.error.code, JsonRpcErrorCode.sessionNotFound) + XCTAssertEqual(response.error.message, "Session not found") + } + + func testToJsonRpcErrorResponseWithNilId() { + let protocolError = MCPProtocolError.parseError(detail: "x") + let response = protocolError.toJsonRpcErrorResponse(id: nil) + XCTAssertNil(response.id) + XCTAssertEqual(response.error.code, JsonRpcErrorCode.parseError) + } + + func testEqualityIgnoresHeadersAndStatus() { + let lhs = MCPProtocolError(code: -1, message: "x", httpStatus: .ok) + let rhs = MCPProtocolError( + code: -1, + message: "x", + httpStatus: .badRequest, + extraHeaders: [("X", "Y")] + ) + XCTAssertEqual(lhs, rhs) + } +} diff --git a/TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift new file mode 100644 index 000000000..dda06aba2 --- /dev/null +++ b/TableProTests/Core/MCP/Transport/MCPStdioMessageTransportTests.swift @@ -0,0 +1,181 @@ +import Foundation +@testable import TablePro +import XCTest + +final class MCPStdioMessageTransportTests: XCTestCase { + private var stdinPipe: Pipe! + private var stdoutPipe: Pipe! + private var logger: FakeBridgeLogger! + + override func setUp() { + super.setUp() + stdinPipe = Pipe() + stdoutPipe = Pipe() + logger = FakeBridgeLogger() + } + + override func tearDown() { + stdinPipe = nil + stdoutPipe = nil + logger = nil + super.tearDown() + } + + func testReceivesValidLine() async throws { + let transport = makeTransport() + + let message = JsonRpcMessage.request( + JsonRpcRequest(id: .number(1), method: "ping", params: nil) + ) + let line = try JsonRpcCodec.encodeLine(message) + stdinPipe.fileHandleForWriting.write(line) + + let received = try await firstInbound(transport: transport) + XCTAssertEqual(received, message) + await transport.close() + } + + func testSkipsMalformedLineAndContinues() async throws { + let transport = makeTransport() + + stdinPipe.fileHandleForWriting.write(Data("not json at all\n".utf8)) + + let valid = JsonRpcMessage.notification( + JsonRpcNotification(method: "notifications/initialized", params: nil) + ) + try stdinPipe.fileHandleForWriting.write(contentsOf: try JsonRpcCodec.encodeLine(valid)) + + let received = try await firstInbound(transport: transport) + XCTAssertEqual(received, valid) + XCTAssertTrue(logger.entries.contains { $0.level == .warning && $0.message.contains("malformed") }) + await transport.close() + } + + func testHandlesBytesSplitAcrossWrites() async throws { + let transport = makeTransport() + + let message = JsonRpcMessage.request( + JsonRpcRequest(id: .number(42), method: "tools/list", params: nil) + ) + let line = try JsonRpcCodec.encodeLine(message) + let half = line.count / 2 + stdinPipe.fileHandleForWriting.write(Data(line.prefix(half))) + try await Task.sleep(nanoseconds: 50_000_000) + stdinPipe.fileHandleForWriting.write(Data(line.suffix(from: half))) + + let received = try await firstInbound(transport: transport) + XCTAssertEqual(received, message) + await transport.close() + } + + func testSendWritesValidJsonRpcLineToStdout() async throws { + let transport = makeTransport() + + let message = JsonRpcMessage.successResponse( + JsonRpcSuccessResponse(id: .number(3), result: .object(["ok": .bool(true)])) + ) + try await transport.send(message) + + try await Task.sleep(nanoseconds: 50_000_000) + + let written = stdoutPipe.fileHandleForReading.availableData + XCTAssertFalse(written.isEmpty) + XCTAssertEqual(written.last, 0x0A) + let trimmed = written.dropLast() + let decoded = try JsonRpcCodec.decode(trimmed) + XCTAssertEqual(decoded, message) + + await transport.close() + } + + func testInboundFinishesOnEof() async throws { + let transport = makeTransport() + + try stdinPipe.fileHandleForWriting.close() + + var iterator = transport.inbound.makeAsyncIterator() + let value = try await iterator.next() + XCTAssertNil(value) + + await transport.close() + } + + func testCloseIsIdempotent() async { + let transport = makeTransport() + await transport.close() + await transport.close() + } + + func testSendAfterCloseThrows() async { + let transport = makeTransport() + await transport.close() + + let message = JsonRpcMessage.notification( + JsonRpcNotification(method: "ping", params: nil) + ) + do { + try await transport.send(message) + XCTFail("Expected throw") + } catch let error as MCPTransportError { + XCTAssertEqual(error, .closed) + } catch { + XCTFail("Unexpected error \(error)") + } + } + + private func makeTransport() -> MCPStdioMessageTransport { + MCPStdioMessageTransport( + stdin: stdinPipe.fileHandleForReading, + stdout: stdoutPipe.fileHandleForWriting, + errorLogger: logger + ) + } + + private func firstInbound( + transport: MCPStdioMessageTransport, + timeout: TimeInterval = 2.0 + ) async throws -> JsonRpcMessage { + try await withThrowingTaskGroup(of: JsonRpcMessage?.self) { group in + group.addTask { + var iterator = transport.inbound.makeAsyncIterator() + return try await iterator.next() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return nil + } + guard let result = try await group.next(), let value = result else { + group.cancelAll() + throw TestError.timeout + } + group.cancelAll() + return value + } + } +} + +private enum TestError: Error { + case timeout +} + +private final class FakeBridgeLogger: MCPBridgeLogger, @unchecked Sendable { + struct Entry { + let level: MCPBridgeLogLevel + let message: String + } + + private let lock = NSLock() + private var storage: [Entry] = [] + + var entries: [Entry] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func log(_ level: MCPBridgeLogLevel, _ message: String) { + lock.lock() + defer { lock.unlock() } + storage.append(Entry(level: level, message: message)) + } +} diff --git a/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift new file mode 100644 index 000000000..6a0e3e974 --- /dev/null +++ b/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift @@ -0,0 +1,528 @@ +import Foundation +import Network +@testable import TablePro +import XCTest + +final class MCPStreamableHttpClientTransportTests: XCTestCase { + private var server: MockHttpServer! + + override func setUp() async throws { + try await super.setUp() + server = MockHttpServer() + try await server.start() + } + + override func tearDown() async throws { + await server.stop() + server = nil + try await super.tearDown() + } + + func testJsonResponseArrivesOnInbound() async throws { + let response = JsonRpcMessage.successResponse( + JsonRpcSuccessResponse(id: .number(1), result: .object(["ok": .bool(true)])) + ) + let body = try JsonRpcCodec.encode(response) + await server.setResponder { _ in + MockHttpResponse(status: 200, headers: [("Content-Type", "application/json")], body: body) + } + + let transport = makeTransport() + let request = JsonRpcMessage.request( + JsonRpcRequest(id: .number(1), method: "ping", params: nil) + ) + try await transport.send(request) + + let received = try await firstInbound(transport: transport) + XCTAssertEqual(received, response) + await transport.close() + } + + func testSseResponseDeliversFramesIncrementally() async throws { + let frame1 = JsonRpcMessage.notification( + JsonRpcNotification(method: "notifications/progress", params: .object(["progress": .int(50)])) + ) + let frame2 = JsonRpcMessage.successResponse( + JsonRpcSuccessResponse(id: .number(2), result: .object(["done": .bool(true)])) + ) + let payload1 = try JsonRpcCodec.encode(frame1) + let payload2 = try JsonRpcCodec.encode(frame2) + let body1 = "data: \(String(data: payload1, encoding: .utf8) ?? "")\n\n" + let body2 = "data: \(String(data: payload2, encoding: .utf8) ?? "")\n\n" + + await server.setResponder { _ in + MockHttpResponse( + status: 200, + headers: [("Content-Type", "text/event-stream")], + body: Data((body1 + body2).utf8) + ) + } + + let transport = makeTransport() + let request = JsonRpcMessage.request( + JsonRpcRequest(id: .number(2), method: "tools/run", params: nil) + ) + try await transport.send(request) + + let received = try await collectInbound(transport: transport, count: 2) + XCTAssertEqual(received[0], frame1) + XCTAssertEqual(received[1], frame2) + await transport.close() + } + + func testHttp404SynthesizesSessionNotFoundError() async throws { + await server.setResponder { _ in + MockHttpResponse( + status: 404, + headers: [("Content-Type", "text/plain")], + body: Data("Session not found".utf8) + ) + } + + let transport = makeTransport() + let request = JsonRpcMessage.request( + JsonRpcRequest(id: .number(7), method: "tools/list", params: nil) + ) + try await transport.send(request) + + let received = try await firstInbound(transport: transport) + guard case .errorResponse(let response) = received else { + XCTFail("Expected errorResponse, got \(received)") + return + } + XCTAssertEqual(response.id, .number(7)) + XCTAssertEqual(response.error.code, JsonRpcErrorCode.sessionNotFound) + await transport.close() + } + + func testHttp401IncludesUnauthenticatedError() async throws { + await server.setResponder { _ in + MockHttpResponse( + status: 401, + headers: [ + ("Content-Type", "text/plain"), + ("WWW-Authenticate", "Bearer realm=\"TablePro\"") + ], + body: Data("Unauthenticated".utf8) + ) + } + + let transport = makeTransport() + let request = JsonRpcMessage.request( + JsonRpcRequest(id: .number(99), method: "tools/list", params: nil) + ) + try await transport.send(request) + + let received = try await firstInbound(transport: transport) + guard case .errorResponse(let response) = received else { + XCTFail("Expected errorResponse, got \(received)") + return + } + XCTAssertEqual(response.id, .number(99)) + XCTAssertEqual(response.error.code, JsonRpcErrorCode.sessionNotFound) + XCTAssertEqual(response.error.message, "Unauthenticated") + await transport.close() + } + + func testHttp500ProducesInternalError() async throws { + await server.setResponder { _ in + MockHttpResponse( + status: 500, + headers: [("Content-Type", "text/plain")], + body: Data("kaboom".utf8) + ) + } + + let transport = makeTransport() + let request = JsonRpcMessage.request( + JsonRpcRequest(id: .number(5), method: "x", params: nil) + ) + try await transport.send(request) + + let received = try await firstInbound(transport: transport) + guard case .errorResponse(let response) = received else { + XCTFail("Expected errorResponse, got \(received)") + return + } + XCTAssertEqual(response.id, .number(5)) + XCTAssertEqual(response.error.code, JsonRpcErrorCode.internalError) + await transport.close() + } + + func testServerEmittedJsonRpcErrorIsForwarded() async throws { + let serverError = JsonRpcMessage.errorResponse( + JsonRpcErrorResponse( + id: .number(8), + error: JsonRpcError(code: -32_007, message: "policy denied") + ) + ) + let body = try JsonRpcCodec.encode(serverError) + await server.setResponder { _ in + MockHttpResponse( + status: 403, + headers: [("Content-Type", "application/json")], + body: body + ) + } + + let transport = makeTransport() + let request = JsonRpcMessage.request( + JsonRpcRequest(id: .number(8), method: "x", params: nil) + ) + try await transport.send(request) + + let received = try await firstInbound(transport: transport) + guard case .errorResponse(let response) = received else { + XCTFail("Expected errorResponse") + return + } + XCTAssertEqual(response.error.code, -32_007) + XCTAssertEqual(response.error.message, "policy denied") + await transport.close() + } + + func testCapturesSessionIdFromResponse() async throws { + let response = JsonRpcMessage.successResponse( + JsonRpcSuccessResponse(id: .number(1), result: .object(["ok": .bool(true)])) + ) + let body = try JsonRpcCodec.encode(response) + await server.setResponder { _ in + MockHttpResponse( + status: 200, + headers: [ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", "session-xyz") + ], + body: body + ) + } + + let transport = makeTransport() + try await transport.send(JsonRpcMessage.request( + JsonRpcRequest(id: .number(1), method: "initialize", params: nil) + )) + _ = try await firstInbound(transport: transport) + + await server.setResponder { received in + let sessionHeader = received.headers.first { $0.0.lowercased() == "mcp-session-id" }?.1 + let resultBody = try? JsonRpcCodec.encode(.successResponse( + JsonRpcSuccessResponse( + id: .number(2), + result: .object(["session": .string(sessionHeader ?? "")]) + ) + )) + return MockHttpResponse( + status: 200, + headers: [("Content-Type", "application/json")], + body: resultBody ?? Data() + ) + } + + try await transport.send(JsonRpcMessage.request( + JsonRpcRequest(id: .number(2), method: "tools/list", params: nil) + )) + let second = try await firstInbound(transport: transport) + guard case .successResponse(let success) = second else { + XCTFail("Expected successResponse") + return + } + XCTAssertEqual(success.result["session"]?.stringValue, "session-xyz") + + await transport.close() + } + + private func makeTransport() -> MCPStreamableHttpClientTransport { + let url = URL(string: "http://127.0.0.1:\(server.port)/mcp")! + let configuration = MCPStreamableHttpClientConfiguration( + endpoint: url, + bearerToken: "test-token", + tlsCertFingerprint: nil, + requestTimeout: .seconds(5), + serverInitiatedStream: false + ) + return MCPStreamableHttpClientTransport(configuration: configuration, errorLogger: nil) + } + + private func firstInbound( + transport: MCPStreamableHttpClientTransport, + timeout: TimeInterval = 3.0 + ) async throws -> JsonRpcMessage { + try await withThrowingTaskGroup(of: JsonRpcMessage?.self) { group in + group.addTask { + var iterator = transport.inbound.makeAsyncIterator() + return try await iterator.next() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return nil + } + guard let result = try await group.next(), let value = result else { + group.cancelAll() + throw TransportTestError.timeout + } + group.cancelAll() + return value + } + } + + private func collectInbound( + transport: MCPStreamableHttpClientTransport, + count: Int, + timeout: TimeInterval = 3.0 + ) async throws -> [JsonRpcMessage] { + try await withThrowingTaskGroup(of: [JsonRpcMessage]?.self) { group in + group.addTask { + var iterator = transport.inbound.makeAsyncIterator() + var collected: [JsonRpcMessage] = [] + while collected.count < count { + guard let next = try await iterator.next() else { break } + collected.append(next) + } + return collected + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return nil + } + guard let result = try await group.next(), let value = result else { + group.cancelAll() + throw TransportTestError.timeout + } + group.cancelAll() + return value + } + } +} + +private enum TransportTestError: Error { + case timeout +} + +private struct MockHttpRequest: Sendable { + let method: String + let path: String + let headers: [(String, String)] + let body: Data +} + +private struct MockHttpResponse: Sendable { + let status: Int + let headers: [(String, String)] + let body: Data +} + +private actor MockServerState { + var responder: (@Sendable (MockHttpRequest) -> MockHttpResponse)? + + func setResponder(_ responder: @escaping @Sendable (MockHttpRequest) -> MockHttpResponse) { + self.responder = responder + } + + func respond(to request: MockHttpRequest) -> MockHttpResponse { + if let responder { + return responder(request) + } + return MockHttpResponse( + status: 500, + headers: [("Content-Type", "text/plain")], + body: Data("no responder".utf8) + ) + } +} + +private final class MockHttpServer: @unchecked Sendable { + private var listener: NWListener? + private let state = MockServerState() + private let lock = NSLock() + private var assignedPort: UInt16 = 0 + private var connections: [NWConnection] = [] + + var port: UInt16 { + lock.lock() + defer { lock.unlock() } + return assignedPort + } + + func setResponder(_ responder: @escaping @Sendable (MockHttpRequest) -> MockHttpResponse) async { + await state.setResponder(responder) + } + + func start() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + let listener = try NWListener(using: params) + lock.lock() + self.listener = listener + lock.unlock() + + let port = self.port + _ = port + + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + if let port = listener.port?.rawValue { + self.lock.lock() + self.assignedPort = port + self.lock.unlock() + } + continuation.resume() + case .failed(let error): + continuation.resume(throwing: error) + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection) + } + listener.start(queue: .global(qos: .userInitiated)) + } catch { + continuation.resume(throwing: error) + } + } + } + + func stop() async { + lock.lock() + let listener = self.listener + let connections = self.connections + self.listener = nil + self.connections = [] + lock.unlock() + listener?.cancel() + for connection in connections { + connection.cancel() + } + } + + private func handle(_ connection: NWConnection) { + lock.lock() + connections.append(connection) + lock.unlock() + + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.readRequest(connection: connection, accumulated: Data()) + case .failed, .cancelled: + break + default: + break + } + } + connection.start(queue: .global(qos: .userInitiated)) + } + + private func readRequest(connection: NWConnection, accumulated: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, isComplete, error in + guard let self else { return } + if let error { + _ = error + connection.cancel() + return + } + var buffer = accumulated + if let data { + buffer.append(data) + } + + if let request = Self.parseRequest(buffer) { + Task { + let response = await self.state.respond(to: request) + let raw = Self.serializeResponse(response) + connection.send(content: raw, completion: .contentProcessed { _ in + connection.cancel() + }) + } + return + } + + if isComplete { + connection.cancel() + return + } + self.readRequest(connection: connection, accumulated: buffer) + } + } + + private static func parseRequest(_ data: Data) -> MockHttpRequest? { + guard let separatorRange = data.range(of: Data("\r\n\r\n".utf8)) else { + return nil + } + let headerData = data[..= 3 else { return nil } + let method = String(parts[0]) + let path = String(parts[1]) + + var headers: [(String, String)] = [] + for line in lines.dropFirst() where !line.isEmpty { + guard let colon = line.firstIndex(of: ":") else { continue } + let key = String(line[line.startIndex.. 0 { + let remaining = data.count - bodyStart + if remaining < contentLength { + return nil + } + body = data.subdata(in: bodyStart..<(bodyStart + contentLength)) + } else { + body = Data() + } + + return MockHttpRequest(method: method, path: path, headers: headers, body: body) + } + + private static func serializeResponse(_ response: MockHttpResponse) -> Data { + var output = "HTTP/1.1 \(response.status) \(reasonPhrase(for: response.status))\r\n" + var headers = response.headers + if !headers.contains(where: { $0.0.lowercased() == "content-length" }) { + headers.append(("Content-Length", "\(response.body.count)")) + } + if !headers.contains(where: { $0.0.lowercased() == "connection" }) { + headers.append(("Connection", "close")) + } + for (key, value) in headers { + output.append("\(key): \(value)\r\n") + } + output.append("\r\n") + var data = Data(output.utf8) + data.append(response.body) + return data + } + + private static func reasonPhrase(for status: Int) -> String { + switch status { + case 200: return "OK" + case 401: return "Unauthorized" + case 403: return "Forbidden" + case 404: return "Not Found" + case 500: return "Internal Server Error" + default: return "Status" + } + } +} From 8b61d70b287d9f4174f1d66032ac6b4cf853ed67 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 14:13:17 +0700 Subject: [PATCH 03/54] refactor(mcp): phase 4 protocol dispatcher + per-method handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProtocolDispatcher actor routes inbound JSON-RPC by method, validates session state and required scopes, registers cancellation tokens, builds request contexts, invokes handlers, and translates typed errors back into JSON-RPC envelopes. Catches MCPProtocolError specifically and falls back to internalError for everything else. Per-method handlers split out as small structs: Initialize, Ping, ToolsList, ToolsCall, ResourcesList/Read/TemplatesList, plus stubs for prompts/logging/ completion. Tools live in their own registry — 19 implementations under Protocol/Tools/ each conforming to MCPToolImplementation. JsonValueLegacy Bridge converts at the MCPConnectionBridge boundary so the legacy JSONValue type can stay on the existing data layer until phase 6. Streaming progress works via MCPProgressEmitter — handlers emit progress events that the transport routes as notifications/progress to the session SSE stream. ExecuteQueryTool uses this at four checkpoints. Cancellation flows through MCPCancellationToken, looked up by (requestId, sessionId) in MCPInflightRegistry; notifications/cancelled triggers token cancel. --- TablePro/Core/MCP/MCPAuthPolicy.swift | 4 +- TablePro/Core/MCP/MCPConnectionBridge.swift | 4 +- .../Handlers/CompletionCompleteHandler.swift | 24 + .../Protocol/Handlers/InitializeHandler.swift | 48 ++ .../Handlers/LoggingSetLevelHandler.swift | 30 ++ .../MCP/Protocol/Handlers/PingHandler.swift | 17 + .../Protocol/Handlers/PromptsGetHandler.swift | 17 + .../Handlers/PromptsListHandler.swift | 18 + .../Handlers/ResourcesListHandler.swift | 76 ++++ .../Handlers/ResourcesReadHandler.swift | 169 +++++++ .../ResourcesTemplatesListHandler.swift | 33 ++ .../Protocol/Handlers/ToolsCallHandler.swift | 40 ++ .../Protocol/Handlers/ToolsListHandler.swift | 22 + .../MCP/Protocol/MCPCancellationToken.swift | 36 ++ .../MCP/Protocol/MCPInflightRegistry.swift | 28 ++ .../Core/MCP/Protocol/MCPMethodHandler.swift | 35 ++ .../MCP/Protocol/MCPProgressEmitter.swift | 52 +++ .../MCP/Protocol/MCPProtocolDispatcher.swift | 228 ++++++++++ .../Core/MCP/Protocol/MCPRequestContext.swift | 50 +++ .../ConfirmDestructiveOperationTool.swift | 91 ++++ .../Core/MCP/Protocol/Tools/ConnectTool.swift | 33 ++ .../Protocol/Tools/DescribeTableTool.swift | 47 ++ .../MCP/Protocol/Tools/DisconnectTool.swift | 34 ++ .../MCP/Protocol/Tools/ExecuteQueryTool.swift | 169 +++++++ .../MCP/Protocol/Tools/ExportDataTool.swift | 317 ++++++++++++++ .../Protocol/Tools/FocusQueryTabTool.swift | 59 +++ .../Tools/GetConnectionStatusTool.swift | 30 ++ .../MCP/Protocol/Tools/GetTableDdlTool.swift | 45 ++ .../Tools/JsonValueLegacyBridge.swift | 51 +++ .../Protocol/Tools/ListConnectionsTool.swift | 24 + .../Protocol/Tools/ListDatabasesTool.swift | 30 ++ .../Protocol/Tools/ListRecentTabsTool.swift | 61 +++ .../MCP/Protocol/Tools/ListSchemasTool.swift | 40 ++ .../MCP/Protocol/Tools/ListTablesTool.swift | 56 +++ .../Protocol/Tools/MCPArgumentDecoder.swift | 64 +++ .../Tools/MCPToolImplementation.swift | 63 +++ .../MCP/Protocol/Tools/MCPToolRegistry.swift | 29 ++ .../MCP/Protocol/Tools/MCPToolServices.swift | 11 + .../Tools/OpenConnectionWindowTool.swift | 63 +++ .../MCP/Protocol/Tools/OpenTableTabTool.swift | 83 ++++ .../Tools/SearchQueryHistoryTool.swift | 102 +++++ .../Protocol/Tools/SwitchDatabaseTool.swift | 41 ++ .../MCP/Protocol/Tools/SwitchSchemaTool.swift | 41 ++ .../Tools/ToolConnectionMetadata.swift | 28 ++ .../Protocol/Tools/ToolQueryExecutor.swift | 46 ++ .../MCPProtocolHandlerTestSupport.swift | 48 ++ .../MCP/Helpers/MCPProtocolTestStubs.swift | 232 ++++++++++ .../Handlers/InitializeHandlerTests.swift | 135 ++++++ .../LoggingSetLevelHandlerTests.swift | 100 +++++ .../Protocol/Handlers/PingHandlerTests.swift | 76 ++++ .../Handlers/PromptsListHandlerTests.swift | 68 +++ .../Handlers/ResourcesListHandlerTests.swift | 91 ++++ .../Handlers/ResourcesReadHandlerTests.swift | 133 ++++++ .../Handlers/ToolsCallHandlerTests.swift | 118 +++++ .../Handlers/ToolsListHandlerTests.swift | 80 ++++ .../Protocol/MCPArgumentDecoderTests.swift | 167 +++++++ .../Protocol/MCPCancellationTokenTests.swift | 117 +++++ .../Protocol/MCPInflightRegistryTests.swift | 112 +++++ .../Protocol/MCPProgressEmitterTests.swift | 160 +++++++ .../Protocol/MCPProtocolDispatcherTests.swift | 412 ++++++++++++++++++ ...ConfirmDestructiveOperationToolTests.swift | 91 ++++ .../MCP/Protocol/Tools/ConnectToolTests.swift | 54 +++ .../Tools/ExecuteQueryToolTests.swift | 192 ++++++++ .../Tools/SwitchDatabaseToolTests.swift | 58 +++ 64 files changed, 5031 insertions(+), 2 deletions(-) create mode 100644 TablePro/Core/MCP/Protocol/Handlers/CompletionCompleteHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/Handlers/LoggingSetLevelHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/Handlers/PingHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/Handlers/PromptsGetHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/Handlers/PromptsListHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/Handlers/ResourcesListHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/Handlers/ResourcesTemplatesListHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/Handlers/ToolsListHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/MCPCancellationToken.swift create mode 100644 TablePro/Core/MCP/Protocol/MCPInflightRegistry.swift create mode 100644 TablePro/Core/MCP/Protocol/MCPMethodHandler.swift create mode 100644 TablePro/Core/MCP/Protocol/MCPProgressEmitter.swift create mode 100644 TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift create mode 100644 TablePro/Core/MCP/Protocol/MCPRequestContext.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/DisconnectTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/JsonValueLegacyBridge.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/MCPArgumentDecoder.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/MCPToolServices.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift create mode 100644 TableProTests/Core/MCP/Helpers/MCPProtocolHandlerTestSupport.swift create mode 100644 TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift create mode 100644 TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Handlers/LoggingSetLevelHandlerTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Handlers/PingHandlerTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/MCPArgumentDecoderTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/MCPCancellationTokenTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/MCPProgressEmitterTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/ConnectToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/SwitchDatabaseToolTests.swift diff --git a/TablePro/Core/MCP/MCPAuthPolicy.swift b/TablePro/Core/MCP/MCPAuthPolicy.swift index 2fa2eee60..5421e7431 100644 --- a/TablePro/Core/MCP/MCPAuthPolicy.swift +++ b/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -20,9 +20,11 @@ enum AuthDecision: Sendable { case denied(reason: String) } -actor MCPAuthPolicy { +public actor MCPAuthPolicy { private static let logger = Logger(subsystem: "com.TablePro", category: "MCPAuthPolicy") + public init() {} + private var sessionApprovals: [String: Set] = [:] private let approvalDedup = OnceTask() diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index f60ff6e29..d56156b27 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -8,9 +8,11 @@ import Foundation import os -actor MCPConnectionBridge { +public actor MCPConnectionBridge { private static let logger = Logger(subsystem: "com.TablePro", category: "MCPConnectionBridge") + public init() {} + func listConnections() async -> JSONValue { let (connections, activeSessions) = await MainActor.run { let conns = ConnectionStorage.shared.loadConnections() diff --git a/TablePro/Core/MCP/Protocol/Handlers/CompletionCompleteHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/CompletionCompleteHandler.swift new file mode 100644 index 000000000..767259ce8 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Handlers/CompletionCompleteHandler.swift @@ -0,0 +1,24 @@ +import Foundation +import os + +public struct CompletionCompleteHandler: MCPMethodHandler { + public static let method = "completion/complete" + public static let requiredScopes: Set = [] + public static let allowedSessionStates: Set = [.ready] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Completion") + + public init() {} + + public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + Self.logger.debug("completion/complete returning empty result") + let result: JsonValue = .object([ + "completion": .object([ + "values": .array([]), + "total": .int(0), + "hasMore": .bool(false) + ]) + ]) + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) + } +} diff --git a/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift new file mode 100644 index 000000000..f64b0ca2c --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift @@ -0,0 +1,48 @@ +import Foundation +import os + +public struct InitializeHandler: MCPMethodHandler { + public static let method = "initialize" + public static let requiredScopes: Set = [] + public static let allowedSessionStates: Set = [.uninitialized] + + public static let supportedProtocolVersion = "2025-03-26" + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Handler.Initialize") + + public init() {} + + public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + let protocolVersion = params?["protocolVersion"]?.stringValue ?? Self.supportedProtocolVersion + let clientCapabilities = params?["capabilities"] + let clientName = params?["clientInfo"]?["name"]?.stringValue ?? "unknown" + let clientVersion = params?["clientInfo"]?["version"]?.stringValue + + let info = MCPClientInfo(name: clientName, version: clientVersion) + await context.session.recordInitialize( + clientInfo: info, + protocolVersion: protocolVersion, + capabilities: clientCapabilities + ) + + let result: JsonValue = .object([ + "protocolVersion": .string(Self.supportedProtocolVersion), + "capabilities": .object([ + "tools": .object(["listChanged": .bool(false)]), + "resources": .object([ + "listChanged": .bool(false), + "subscribe": .bool(false) + ]), + "prompts": .object(["listChanged": .bool(false)]), + "logging": .object([:]) + ]), + "serverInfo": .object([ + "name": .string("tablepro"), + "version": .string("1.0.0") + ]) + ]) + + Self.logger.info("Initialize: client=\(clientName, privacy: .public) version=\(clientVersion ?? "-", privacy: .public)") + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) + } +} diff --git a/TablePro/Core/MCP/Protocol/Handlers/LoggingSetLevelHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/LoggingSetLevelHandler.swift new file mode 100644 index 000000000..7526504b0 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Handlers/LoggingSetLevelHandler.swift @@ -0,0 +1,30 @@ +import Foundation +import os + +public struct LoggingSetLevelHandler: MCPMethodHandler { + public static let method = "logging/setLevel" + public static let requiredScopes: Set = [] + public static let allowedSessionStates: Set = [.ready] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Logging") + + public static let supportedLevels: Set = [ + "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency" + ] + + public init() {} + + public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + guard case .string(let level)? = params?["level"] else { + throw MCPProtocolError.invalidParams(detail: "Missing required parameter: level") + } + + let normalized = level.lowercased() + guard Self.supportedLevels.contains(normalized) else { + throw MCPProtocolError.invalidParams(detail: "Unknown log level: \(level)") + } + + Self.logger.notice("Client requested log level: \(normalized, privacy: .public)") + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: .object([:])) + } +} diff --git a/TablePro/Core/MCP/Protocol/Handlers/PingHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/PingHandler.swift new file mode 100644 index 000000000..b95057b33 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Handlers/PingHandler.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct PingHandler: MCPMethodHandler { + public static let method = "ping" + public static let requiredScopes: Set = [] + public static let allowedSessionStates: Set = [.uninitialized, .ready] + + public init() {} + + public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + await context.session.touch(now: await context.clock.now()) + return MCPMethodHandlerHelpers.successResponse( + id: context.requestId, + result: .object([:]) + ) + } +} diff --git a/TablePro/Core/MCP/Protocol/Handlers/PromptsGetHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/PromptsGetHandler.swift new file mode 100644 index 000000000..c3131efd1 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Handlers/PromptsGetHandler.swift @@ -0,0 +1,17 @@ +import Foundation +import os + +public struct PromptsGetHandler: MCPMethodHandler { + public static let method = "prompts/get" + public static let requiredScopes: Set = [] + public static let allowedSessionStates: Set = [.ready] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Prompts") + + public init() {} + + public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + Self.logger.debug("prompts/get rejected: server has no prompts") + throw MCPProtocolError.methodNotFound(method: "prompts/get") + } +} diff --git a/TablePro/Core/MCP/Protocol/Handlers/PromptsListHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/PromptsListHandler.swift new file mode 100644 index 000000000..e92043b57 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Handlers/PromptsListHandler.swift @@ -0,0 +1,18 @@ +import Foundation +import os + +public struct PromptsListHandler: MCPMethodHandler { + public static let method = "prompts/list" + public static let requiredScopes: Set = [] + public static let allowedSessionStates: Set = [.ready] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Prompts") + + public init() {} + + public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + Self.logger.debug("prompts/list returning empty list") + let result: JsonValue = .object(["prompts": .array([])]) + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) + } +} diff --git a/TablePro/Core/MCP/Protocol/Handlers/ResourcesListHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ResourcesListHandler.swift new file mode 100644 index 000000000..7425dde17 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Handlers/ResourcesListHandler.swift @@ -0,0 +1,76 @@ +import Foundation +import os + +public struct ResourcesListHandler: MCPMethodHandler { + public static let method = "resources/list" + public static let requiredScopes: Set = [.resourcesRead] + public static let allowedSessionStates: Set = [.ready] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Resources") + + private let services: MCPToolServices + + public init(services: MCPToolServices) { + self.services = services + } + + public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + var resources: [JsonValue] = [] + resources.append(Self.staticConnectionsResource()) + + let connectedItems = await Self.connectedConnectionItems(services: services) + for item in connectedItems { + resources.append(Self.schemaResource(for: item)) + resources.append(Self.historyResource(for: item)) + } + + let result: JsonValue = .object(["resources": .array(resources)]) + Self.logger.debug("resources/list returned \(resources.count, privacy: .public) entries") + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) + } + + private static func staticConnectionsResource() -> JsonValue { + .object([ + "uri": .string("tablepro://connections"), + "name": .string(String(localized: "Saved Connections")), + "description": .string(String(localized: "List of all saved database connections with metadata")), + "mimeType": .string("application/json") + ]) + } + + private struct ConnectedConnectionItem: Sendable { + let id: String + let name: String + } + + private static func connectedConnectionItems(services: MCPToolServices) async -> [ConnectedConnectionItem] { + let legacy = await services.connectionBridge.listConnections() + let value = JsonValue.fromLegacy(legacy) + guard let connections = value["connections"]?.arrayValue else { return [] } + + return connections.compactMap { entry -> ConnectedConnectionItem? in + guard let id = entry["id"]?.stringValue else { return nil } + guard entry["is_connected"]?.boolValue == true else { return nil } + let name = entry["name"]?.stringValue ?? id + return ConnectedConnectionItem(id: id, name: name) + } + } + + private static func schemaResource(for item: ConnectedConnectionItem) -> JsonValue { + .object([ + "uri": .string("tablepro://connections/\(item.id)/schema"), + "name": .string(String(format: String(localized: "Schema for %@"), item.name)), + "description": .string(String(localized: "Tables, columns, indexes, and foreign keys for the connected database")), + "mimeType": .string("application/json") + ]) + } + + private static func historyResource(for item: ConnectedConnectionItem) -> JsonValue { + .object([ + "uri": .string("tablepro://connections/\(item.id)/history"), + "name": .string(String(format: String(localized: "Query history for %@"), item.name)), + "description": .string(String(localized: "Recent query history for this connection")), + "mimeType": .string("application/json") + ]) + } +} diff --git a/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift new file mode 100644 index 000000000..237a80ec8 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift @@ -0,0 +1,169 @@ +import Foundation +import os + +public struct ResourcesReadHandler: MCPMethodHandler { + public static let method = "resources/read" + public static let requiredScopes: Set = [.resourcesRead] + public static let allowedSessionStates: Set = [.ready] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Resources") + + private let services: MCPToolServices + + public init(services: MCPToolServices) { + self.services = services + } + + public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + guard case .string(let uri)? = params?["uri"] else { + throw MCPProtocolError.invalidParams(detail: "Missing required parameter: uri") + } + + let route = try Self.parseRoute(uri: uri) + let payload = try await Self.fetchPayload(for: route, services: services) + let text = Self.encodeJsonString(payload) + + let result: JsonValue = .object([ + "contents": .array([ + .object([ + "uri": .string(uri), + "mimeType": .string("application/json"), + "text": .string(text) + ]) + ]) + ]) + + Self.logger.debug("resources/read uri=\(uri, privacy: .public)") + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) + } + + private enum ResourceRoute { + case connectionsList + case connectionSchema(connectionId: UUID) + case connectionHistory(connectionId: UUID, limit: Int, search: String?, dateFilter: String?) + } + + private static func parseRoute(uri: String) throws -> ResourceRoute { + guard let components = URLComponents(string: uri) else { + throw MCPProtocolError.invalidParams(detail: "Malformed URI: \(uri)") + } + guard components.scheme == "tablepro" else { + throw MCPProtocolError.invalidParams(detail: "Unsupported URI scheme: \(components.scheme ?? "nil")") + } + + let segments = pathSegments(from: uri) + + if segments == ["connections"] { + return .connectionsList + } + + guard segments.count == 3, segments[0] == "connections" else { + throw MCPProtocolError( + code: JsonRpcErrorCode.methodNotFound, + message: "Unknown resource URI: \(uri)", + httpStatus: .notFound + ) + } + + guard let connectionId = UUID(uuidString: segments[1]) else { + throw MCPProtocolError.invalidParams(detail: "Invalid connection UUID in URI") + } + + switch segments[2] { + case "schema": + return .connectionSchema(connectionId: connectionId) + case "history": + let queryItems = components.queryItems ?? [] + let rawLimit = queryItems.first(where: { $0.name == "limit" })?.value.flatMap { Int($0) } ?? 50 + let limit = min(max(rawLimit, 1), 500) + let search = queryItems.first(where: { $0.name == "search" })?.value + let dateFilter = queryItems.first(where: { $0.name == "date_filter" })?.value + return .connectionHistory( + connectionId: connectionId, + limit: limit, + search: search, + dateFilter: dateFilter + ) + default: + throw MCPProtocolError( + code: JsonRpcErrorCode.methodNotFound, + message: "Unknown resource URI: \(uri)", + httpStatus: .notFound + ) + } + } + + private static func fetchPayload(for route: ResourceRoute, services: MCPToolServices) async throws -> JsonValue { + switch route { + case .connectionsList: + let legacy = await services.connectionBridge.listConnections() + return JsonValue.fromLegacy(legacy) + + case .connectionSchema(let connectionId): + do { + let legacy = try await services.connectionBridge.fetchSchemaResource(connectionId: connectionId) + return JsonValue.fromLegacy(legacy) + } catch let error as MCPError { + throw mapLegacyError(error) + } + + case .connectionHistory(let connectionId, let limit, let search, let dateFilter): + do { + let legacy = try await services.connectionBridge.fetchHistoryResource( + connectionId: connectionId, + limit: limit, + search: search, + dateFilter: dateFilter + ) + return JsonValue.fromLegacy(legacy) + } catch let error as MCPError { + throw mapLegacyError(error) + } + } + } + + private static func mapLegacyError(_ error: MCPError) -> MCPProtocolError { + switch error { + case .invalidParams(let detail): + return MCPProtocolError.invalidParams(detail: detail) + case .invalidRequest(let detail): + return MCPProtocolError.invalidRequest(detail: detail) + case .notConnected(let id): + return MCPProtocolError.invalidParams(detail: "Connection not active: \(id.uuidString)") + case .forbidden(let reason, _): + return MCPProtocolError.forbidden(reason: reason) + case .methodNotFound(let method): + return MCPProtocolError.methodNotFound(method: method) + case .timeout(let detail, _): + return MCPProtocolError( + code: JsonRpcErrorCode.requestCancelled, + message: "Timeout: \(detail)", + httpStatus: .ok + ) + default: + return MCPProtocolError.internalError(detail: String(describing: error)) + } + } + + private static func pathSegments(from uri: String) -> [String] { + guard let range = uri.range(of: "://") else { return [] } + let afterScheme = String(uri[range.upperBound...]) + let pathOnly: String + if let queryStart = afterScheme.firstIndex(of: "?") { + pathOnly = String(afterScheme[.. String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(value), + let string = String(data: data, encoding: .utf8) else { + return "{}" + } + return string + } +} diff --git a/TablePro/Core/MCP/Protocol/Handlers/ResourcesTemplatesListHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ResourcesTemplatesListHandler.swift new file mode 100644 index 000000000..9e32509b1 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Handlers/ResourcesTemplatesListHandler.swift @@ -0,0 +1,33 @@ +import Foundation +import os + +public struct ResourcesTemplatesListHandler: MCPMethodHandler { + public static let method = "resources/templates/list" + public static let requiredScopes: Set = [.resourcesRead] + public static let allowedSessionStates: Set = [.ready] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Resources") + + public init() {} + + public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + let templates: [JsonValue] = [ + .object([ + "uriTemplate": .string("tablepro://connections/{id}/schema"), + "name": .string(String(localized: "Database Schema")), + "description": .string(String(localized: "Tables, columns, indexes, and foreign keys for a connected database")), + "mimeType": .string("application/json") + ]), + .object([ + "uriTemplate": .string("tablepro://connections/{id}/history"), + "name": .string(String(localized: "Query History")), + "description": .string(String(localized: "Recent query history for a connection (supports ?limit=, ?search=, ?date_filter=)")), + "mimeType": .string("application/json") + ]) + ] + + let result: JsonValue = .object(["resourceTemplates": .array(templates)]) + Self.logger.debug("resources/templates/list returned \(templates.count, privacy: .public) templates") + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) + } +} diff --git a/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift new file mode 100644 index 000000000..20935de05 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift @@ -0,0 +1,40 @@ +import Foundation +import os + +public struct ToolsCallHandler: MCPMethodHandler { + public static let method = "tools/call" + public static let requiredScopes: Set = [.toolsRead] + public static let allowedSessionStates: Set = [.ready] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") + + private let services: MCPToolServices + + public init(services: MCPToolServices) { + self.services = services + } + + public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + guard case .object(let object)? = params else { + throw MCPProtocolError.invalidParams(detail: "params must be object") + } + guard case .string(let toolName)? = object["name"] else { + throw MCPProtocolError.invalidParams(detail: "missing tool name") + } + let arguments = object["arguments"] ?? .object([:]) + + guard let tool = MCPToolRegistry.tool(named: toolName) else { + throw MCPProtocolError.methodNotFound(method: "tools/call:\(toolName)") + } + + let toolType = type(of: tool) + if !toolType.requiredScopes.isSubset(of: context.principal.scopes) { + throw MCPProtocolError.forbidden(reason: "Tool '\(toolName)' requires additional scopes") + } + + Self.logger.info("tools/call name=\(toolName, privacy: .public)") + + let result = try await tool.call(arguments: arguments, context: context, services: services) + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result.asJsonValue()) + } +} diff --git a/TablePro/Core/MCP/Protocol/Handlers/ToolsListHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ToolsListHandler.swift new file mode 100644 index 000000000..5e58eaa29 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Handlers/ToolsListHandler.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct ToolsListHandler: MCPMethodHandler { + public static let method = "tools/list" + public static let requiredScopes: Set = [.toolsRead] + public static let allowedSessionStates: Set = [.ready] + + public init() {} + + public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + let tools: [JsonValue] = MCPToolRegistry.allTools.map { tool in + let toolType = type(of: tool) + return .object([ + "name": .string(toolType.name), + "description": .string(toolType.description), + "inputSchema": toolType.inputSchema + ]) + } + let result: JsonValue = .object(["tools": .array(tools)]) + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) + } +} diff --git a/TablePro/Core/MCP/Protocol/MCPCancellationToken.swift b/TablePro/Core/MCP/Protocol/MCPCancellationToken.swift new file mode 100644 index 000000000..7fd6431d3 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/MCPCancellationToken.swift @@ -0,0 +1,36 @@ +import Foundation + +public actor MCPCancellationToken { + private var cancelled: Bool = false + private var handlers: [@Sendable () async -> Void] = [] + + public init() {} + + public func cancel() async { + guard !cancelled else { return } + cancelled = true + let toRun = handlers + handlers.removeAll() + for handler in toRun { + await handler() + } + } + + public func isCancelled() async -> Bool { + cancelled + } + + public func onCancel(_ handler: @Sendable @escaping () async -> Void) async { + if cancelled { + await handler() + return + } + handlers.append(handler) + } + + public func throwIfCancelled() async throws { + if cancelled { + throw CancellationError() + } + } +} diff --git a/TablePro/Core/MCP/Protocol/MCPInflightRegistry.swift b/TablePro/Core/MCP/Protocol/MCPInflightRegistry.swift new file mode 100644 index 000000000..2e85ffa07 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/MCPInflightRegistry.swift @@ -0,0 +1,28 @@ +import Foundation + +actor MCPInflightRegistry { + private struct Key: Hashable { + let sessionId: MCPSessionId + let requestId: JsonRpcId + } + + private var entries: [Key: MCPCancellationToken] = [:] + + func register(requestId: JsonRpcId, sessionId: MCPSessionId, token: MCPCancellationToken) { + entries[Key(sessionId: sessionId, requestId: requestId)] = token + } + + func cancel(requestId: JsonRpcId, sessionId: MCPSessionId) async { + let key = Key(sessionId: sessionId, requestId: requestId) + guard let token = entries.removeValue(forKey: key) else { return } + await token.cancel() + } + + func remove(requestId: JsonRpcId, sessionId: MCPSessionId) { + entries.removeValue(forKey: Key(sessionId: sessionId, requestId: requestId)) + } + + func count() -> Int { + entries.count + } +} diff --git a/TablePro/Core/MCP/Protocol/MCPMethodHandler.swift b/TablePro/Core/MCP/Protocol/MCPMethodHandler.swift new file mode 100644 index 000000000..fb803e420 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/MCPMethodHandler.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum MCPSessionAllowedState: Sendable, Equatable, Hashable { + case uninitialized + case ready +} + +public protocol MCPMethodHandler: Sendable { + static var method: String { get } + static var requiredScopes: Set { get } + static var allowedSessionStates: Set { get } + func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage +} + +public extension MCPMethodHandler { + var method: String { Self.method } + var requiredScopes: Set { Self.requiredScopes } + var allowedSessionStates: Set { Self.allowedSessionStates } +} + +public enum MCPMethodHandlerHelpers { + public static func successResponse(id: JsonRpcId?, result: JsonValue) -> JsonRpcMessage { + guard let id else { + return .errorResponse(JsonRpcErrorResponse( + id: nil, + error: JsonRpcError.invalidRequest(message: "Missing request id") + )) + } + return .successResponse(JsonRpcSuccessResponse(id: id, result: result)) + } + + public static func errorResponse(id: JsonRpcId?, error: MCPProtocolError) -> JsonRpcMessage { + .errorResponse(error.toJsonRpcErrorResponse(id: id)) + } +} diff --git a/TablePro/Core/MCP/Protocol/MCPProgressEmitter.swift b/TablePro/Core/MCP/Protocol/MCPProgressEmitter.swift new file mode 100644 index 000000000..b7c762009 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/MCPProgressEmitter.swift @@ -0,0 +1,52 @@ +import Foundation + +public protocol MCPProgressSink: Sendable { + func sendNotification(_ notification: JsonRpcNotification, toSession sessionId: MCPSessionId) async +} + +public actor MCPProgressEmitter { + private let progressToken: JsonValue? + private let target: any MCPProgressSink + private let sessionId: MCPSessionId + + public init(progressToken: JsonValue?, target: any MCPProgressSink, sessionId: MCPSessionId) { + self.progressToken = progressToken + self.target = target + self.sessionId = sessionId + } + + public func emit(progress: Double, total: Double? = nil, message: String? = nil) async { + guard let progressToken else { return } + + var params: [String: JsonValue] = [ + "progressToken": progressToken, + "progress": .double(progress) + ] + if let total { + params["total"] = .double(total) + } + if let message { + params["message"] = .string(message) + } + + let notification = JsonRpcNotification( + method: "notifications/progress", + params: .object(params) + ) + await target.sendNotification(notification, toSession: sessionId) + } + + public func emitNotification(method: String, params: JsonValue?) async { + let notification = JsonRpcNotification(method: method, params: params) + await target.sendNotification(notification, toSession: sessionId) + } + + public var hasProgressToken: Bool { + progressToken != nil + } + + public static func extractProgressToken(from params: JsonValue?) -> JsonValue? { + guard let meta = params?["_meta"] else { return nil } + return meta["progressToken"] + } +} diff --git a/TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift b/TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift new file mode 100644 index 000000000..87015af7f --- /dev/null +++ b/TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift @@ -0,0 +1,228 @@ +import Foundation +import os + +public actor MCPProtocolDispatcher { + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Dispatcher") + + private let handlers: [String: any MCPMethodHandler] + private let sessionStore: MCPSessionStore + private let progressSink: any MCPProgressSink + private let clock: any MCPClock + private let inflight: MCPInflightRegistry + + public init( + handlers: [any MCPMethodHandler], + sessionStore: MCPSessionStore, + progressSink: any MCPProgressSink, + clock: any MCPClock = MCPSystemClock() + ) { + var map: [String: any MCPMethodHandler] = [:] + for handler in handlers { + map[type(of: handler).method] = handler + } + self.handlers = map + self.sessionStore = sessionStore + self.progressSink = progressSink + self.clock = clock + self.inflight = MCPInflightRegistry() + } + + public func dispatch(_ exchange: MCPInboundExchange) async { + switch exchange.message { + case .request(let request): + await handleRequest(request, exchange: exchange) + case .notification(let notification): + await handleNotification(notification, exchange: exchange) + case .successResponse, .errorResponse: + Self.logger.debug("Ignoring inbound response message") + await exchange.responder.acknowledgeAccepted() + } + } + + public func cancel(requestId: JsonRpcId, sessionId: MCPSessionId) async { + await inflight.cancel(requestId: requestId, sessionId: sessionId) + } + + private func handleRequest(_ request: JsonRpcRequest, exchange: MCPInboundExchange) async { + guard let handler = handlers[request.method] else { + await respondError( + exchange: exchange, + requestId: request.id, + error: .methodNotFound(method: request.method) + ) + return + } + + let session = await resolveOrCreateSession(method: request.method, exchange: exchange) + guard let session else { + await respondError( + exchange: exchange, + requestId: request.id, + error: .sessionNotFound() + ) + return + } + + let allowed = type(of: handler).allowedSessionStates + let stateCheck = await checkSessionState(session: session, allowed: allowed) + if let stateError = stateCheck { + await respondError(exchange: exchange, requestId: request.id, error: stateError) + return + } + + guard let principal = exchange.context.principal else { + await respondError( + exchange: exchange, + requestId: request.id, + error: .unauthenticated() + ) + return + } + + let required = type(of: handler).requiredScopes + if !required.isEmpty, !required.isSubset(of: principal.scopes) { + await respondError( + exchange: exchange, + requestId: request.id, + error: .forbidden(reason: "missing required scopes") + ) + return + } + + await session.touch(now: await clock.now()) + + let token = MCPCancellationToken() + await inflight.register(requestId: request.id, sessionId: session.id, token: token) + + let progressToken = MCPProgressEmitter.extractProgressToken(from: request.params) + let emitter = MCPProgressEmitter( + progressToken: progressToken, + target: progressSink, + sessionId: session.id + ) + + let context = MCPRequestContext( + exchange: exchange, + session: session, + principal: principal, + dispatcher: self, + progress: emitter, + cancellation: token, + clock: clock + ) + + let response = await invokeHandler(handler, params: request.params, context: context, requestId: request.id) + await inflight.remove(requestId: request.id, sessionId: session.id) + await exchange.responder.respond(response, sessionId: session.id) + } + + private func invokeHandler( + _ handler: any MCPMethodHandler, + params: JsonValue?, + context: MCPRequestContext, + requestId: JsonRpcId + ) async -> JsonRpcMessage { + do { + return try await handler.handle(params: params, context: context) + } catch let error as MCPProtocolError { + return MCPMethodHandlerHelpers.errorResponse(id: requestId, error: error) + } catch is CancellationError { + return MCPMethodHandlerHelpers.errorResponse( + id: requestId, + error: MCPProtocolError( + code: JsonRpcErrorCode.requestCancelled, + message: "Request cancelled", + httpStatus: .ok + ) + ) + } catch { + Self.logger.error("Handler threw error: \(error.localizedDescription, privacy: .public)") + return MCPMethodHandlerHelpers.errorResponse( + id: requestId, + error: .internalError(detail: error.localizedDescription) + ) + } + } + + private func handleNotification(_ notification: JsonRpcNotification, exchange: MCPInboundExchange) async { + if notification.method == "notifications/cancelled" { + await handleCancellationNotification(notification, exchange: exchange) + await exchange.responder.acknowledgeAccepted() + return + } + + if notification.method == "notifications/initialized" { + if let sessionId = exchange.context.sessionId, + let session = await sessionStore.session(id: sessionId) { + let state = await session.state + if case .initializing = state { + try? await session.transitionToReady() + } + } + await exchange.responder.acknowledgeAccepted() + return + } + + await exchange.responder.acknowledgeAccepted() + } + + private func handleCancellationNotification( + _ notification: JsonRpcNotification, + exchange: MCPInboundExchange + ) async { + guard let params = notification.params, + let sessionId = exchange.context.sessionId + else { return } + + let requestIdValue = params["requestId"] + let cancelId: JsonRpcId? + switch requestIdValue { + case .string(let value): + cancelId = .string(value) + case .int(let value): + cancelId = .number(Int64(value)) + case .double(let value): + cancelId = .number(Int64(value)) + default: + cancelId = nil + } + + guard let cancelId else { return } + await inflight.cancel(requestId: cancelId, sessionId: sessionId) + } + + private func resolveOrCreateSession(method: String, exchange: MCPInboundExchange) async -> MCPSession? { + if method == "initialize" { + return try? await sessionStore.create() + } + + guard let sessionId = exchange.context.sessionId else { return nil } + return await sessionStore.session(id: sessionId) + } + + private func checkSessionState( + session: MCPSession, + allowed: Set + ) async -> MCPProtocolError? { + let state = await session.state + switch state { + case .initializing: + if allowed.contains(.uninitialized) { return nil } + return .invalidRequest(detail: "Session not initialized") + case .ready: + if allowed.contains(.ready) { return nil } + return .invalidRequest(detail: "Session already initialized") + case .terminated: + return .sessionNotFound(message: "Session terminated") + } + } + + private func respondError( + exchange: MCPInboundExchange, + requestId: JsonRpcId, + error: MCPProtocolError + ) async { + let response = MCPMethodHandlerHelpers.errorResponse(id: requestId, error: error) + await exchange.responder.respond(response, sessionId: exchange.context.sessionId) + } +} diff --git a/TablePro/Core/MCP/Protocol/MCPRequestContext.swift b/TablePro/Core/MCP/Protocol/MCPRequestContext.swift new file mode 100644 index 000000000..707418c4a --- /dev/null +++ b/TablePro/Core/MCP/Protocol/MCPRequestContext.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct MCPRequestContext: Sendable { + public let exchange: MCPInboundExchange + public let session: MCPSession + public let principal: MCPPrincipal + public let dispatcher: MCPProtocolDispatcher + public let progress: MCPProgressEmitter + public let cancellation: MCPCancellationToken + public let clock: any MCPClock + + public init( + exchange: MCPInboundExchange, + session: MCPSession, + principal: MCPPrincipal, + dispatcher: MCPProtocolDispatcher, + progress: MCPProgressEmitter, + cancellation: MCPCancellationToken, + clock: any MCPClock + ) { + self.exchange = exchange + self.session = session + self.principal = principal + self.dispatcher = dispatcher + self.progress = progress + self.cancellation = cancellation + self.clock = clock + } + + public var requestId: JsonRpcId? { + if case .request(let request) = exchange.message { + return request.id + } + return nil + } + + public var sessionId: MCPSessionId { + session.id + } + + public var requestParams: JsonValue? { + if case .request(let request) = exchange.message { + return request.params + } + if case .notification(let notification) = exchange.message { + return notification.params + } + return nil + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift b/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift new file mode 100644 index 000000000..bcff85c0b --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift @@ -0,0 +1,91 @@ +import Foundation +import os + +public struct ConfirmDestructiveOperationTool: MCPToolImplementation { + public static let name = "confirm_destructive_operation" + public static let description = String( + localized: "Execute a destructive DDL query (DROP, TRUNCATE, ALTER...DROP) after explicit confirmation." + ) + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string(String(localized: "UUID of the active connection")) + ]), + "query": .object([ + "type": .string("string"), + "description": .string(String(localized: "The destructive query to execute")) + ]), + "confirmation_phrase": .object([ + "type": .string("string"), + "description": .string(String(localized: "Must be exactly: I understand this is irreversible")) + ]) + ]), + "required": .array([ + .string("connection_id"), + .string("query"), + .string("confirmation_phrase") + ]) + ]) + public static let requiredScopes: Set = [.toolsWrite] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") + private static let requiredPhrase = "I understand this is irreversible" + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let query = try MCPArgumentDecoder.requireString(arguments, key: "query") + let confirmationPhrase = try MCPArgumentDecoder.requireString(arguments, key: "confirmation_phrase") + + guard confirmationPhrase == Self.requiredPhrase else { + throw MCPProtocolError.invalidParams( + detail: "confirmation_phrase must be exactly: \(Self.requiredPhrase)" + ) + } + + guard !QueryClassifier.isMultiStatement(query) else { + throw MCPProtocolError.invalidParams( + detail: "Multi-statement queries are not supported. Send one statement at a time." + ) + } + + let meta = try await ToolConnectionMetadata.resolve(connectionId: connectionId) + + let tier = QueryClassifier.classifyTier(query, databaseType: meta.databaseType) + guard tier == .destructive else { + throw MCPProtocolError.invalidParams( + detail: "This tool only accepts destructive queries (DROP, TRUNCATE, ALTER...DROP). Use execute_query for other queries." + ) + } + + try await services.authPolicy.checkSafeModeDialog( + sql: query, + connectionId: connectionId, + databaseType: meta.databaseType, + safeModeLevel: meta.safeModeLevel + ) + + let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp } + let timeoutSeconds = mcpSettings.queryTimeoutSeconds + + Self.logger.debug("confirm_destructive_operation invoked for connection \(connectionId.uuidString, privacy: .public)") + + let result = try await ToolQueryExecutor.executeAndLog( + services: services, + query: query, + connectionId: connectionId, + databaseName: meta.databaseName, + maxRows: 0, + timeoutSeconds: timeoutSeconds + ) + + return .json(JsonValue.fromLegacy(result)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift b/TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift new file mode 100644 index 000000000..67de055e5 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift @@ -0,0 +1,33 @@ +import Foundation +import os + +public struct ConnectTool: MCPToolImplementation { + public static let name = "connect" + public static let description = String(localized: "Connect to a saved database") + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string(String(localized: "UUID of the saved connection")) + ]) + ]), + "required": .array([.string("connection_id")]) + ]) + public static let requiredScopes: Set = [.toolsRead] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + Self.logger.debug("connect tool invoked for connection \(connectionId.uuidString, privacy: .public)") + let legacy = try await services.connectionBridge.connect(connectionId: connectionId) + return .json(JsonValue.fromLegacy(legacy)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift b/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift new file mode 100644 index 000000000..024b08165 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct DescribeTableTool: MCPToolImplementation { + public static let name = "describe_table" + public static let description = String( + localized: "Get detailed table structure: columns, indexes, foreign keys, and DDL" + ) + public static let requiredScopes: Set = [.toolsRead] + + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string("UUID of the connection") + ]), + "table": .object([ + "type": .string("string"), + "description": .string("Table name") + ]), + "schema": .object([ + "type": .string("string"), + "description": .string("Schema name (uses current if omitted)") + ]) + ]), + "required": .array([.string("connection_id"), .string("table")]) + ]) + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let table = try MCPArgumentDecoder.requireString(arguments, key: "table") + let schema = MCPArgumentDecoder.optionalString(arguments, key: "schema") + + let legacy = try await services.connectionBridge.describeTable( + connectionId: connectionId, + table: table, + schema: schema + ) + return .json(JsonValue.fromLegacy(legacy)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/DisconnectTool.swift b/TablePro/Core/MCP/Protocol/Tools/DisconnectTool.swift new file mode 100644 index 000000000..f82e2d266 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/DisconnectTool.swift @@ -0,0 +1,34 @@ +import Foundation +import os + +public struct DisconnectTool: MCPToolImplementation { + public static let name = "disconnect" + public static let description = String(localized: "Disconnect from a database") + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string(String(localized: "UUID of the connection to disconnect")) + ]) + ]), + "required": .array([.string("connection_id")]) + ]) + public static let requiredScopes: Set = [.toolsWrite] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + Self.logger.debug("disconnect tool invoked for connection \(connectionId.uuidString, privacy: .public)") + try await services.connectionBridge.disconnect(connectionId: connectionId) + let result: JsonValue = .object(["status": .string("disconnected")]) + return .json(result) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift new file mode 100644 index 000000000..41fff8d58 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift @@ -0,0 +1,169 @@ +import Foundation +import os + +public struct ExecuteQueryTool: MCPToolImplementation { + public static let name = "execute_query" + public static let description = String( + localized: "Execute a SQL query. All queries are subject to the connection's safe mode policy. DROP/TRUNCATE/ALTER...DROP must use the confirm_destructive_operation tool." + ) + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string(String(localized: "UUID of the connection")) + ]), + "query": .object([ + "type": .string("string"), + "description": .string(String(localized: "SQL or NoSQL query text")) + ]), + "max_rows": .object([ + "type": .string("integer"), + "description": .string(String(localized: "Maximum rows to return (default 500, max 10000)")) + ]), + "timeout_seconds": .object([ + "type": .string("integer"), + "description": .string(String(localized: "Query timeout in seconds (default 30, max 300)")) + ]), + "database": .object([ + "type": .string("string"), + "description": .string(String(localized: "Switch to this database before executing")) + ]), + "schema": .object([ + "type": .string("string"), + "description": .string(String(localized: "Switch to this schema before executing")) + ]) + ]), + "required": .array([.string("connection_id"), .string("query")]) + ]) + public static let requiredScopes: Set = [.toolsRead] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let query = try MCPArgumentDecoder.requireString(arguments, key: "query") + + let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp } + let maxRows = MCPArgumentDecoder.optionalInt( + arguments, + key: "max_rows", + default: mcpSettings.defaultRowLimit, + clamp: 1...mcpSettings.maxRowLimit + ) ?? mcpSettings.defaultRowLimit + let timeoutSeconds = MCPArgumentDecoder.optionalInt( + arguments, + key: "timeout_seconds", + default: mcpSettings.queryTimeoutSeconds, + clamp: 1...300 + ) ?? mcpSettings.queryTimeoutSeconds + let database = MCPArgumentDecoder.optionalString(arguments, key: "database") + let schema = MCPArgumentDecoder.optionalString(arguments, key: "schema") + + guard (query as NSString).length <= 102_400 else { + throw MCPProtocolError.invalidParams(detail: "Query exceeds 100KB limit") + } + + guard !QueryClassifier.isMultiStatement(query) else { + throw MCPProtocolError.invalidParams( + detail: "Multi-statement queries are not supported. Send one statement at a time." + ) + } + + try await throwIfCancelled(context) + await context.progress.emit(progress: 0.0, total: 1.0, message: "Connecting") + + let meta = try await ToolConnectionMetadata.resolve(connectionId: connectionId) + + if let database { + _ = try await services.connectionBridge.switchDatabase( + connectionId: connectionId, + database: database + ) + } + if let schema { + _ = try await services.connectionBridge.switchSchema( + connectionId: connectionId, + schema: schema + ) + } + + try await throwIfCancelled(context) + await context.progress.emit(progress: 0.2, total: 1.0, message: "Executing") + + let tier = QueryClassifier.classifyTier(query, databaseType: meta.databaseType) + try classifyAndAuthorize( + tier: tier, + query: query, + connectionId: connectionId, + meta: meta, + services: services, + context: context + ) + + try await services.authPolicy.checkSafeModeDialog( + sql: query, + connectionId: connectionId, + databaseType: meta.databaseType, + safeModeLevel: meta.safeModeLevel + ) + + Self.logger.debug("execute_query invoked for connection \(connectionId.uuidString, privacy: .public)") + + let result = try await ToolQueryExecutor.executeAndLog( + services: services, + query: query, + connectionId: connectionId, + databaseName: meta.databaseName, + maxRows: maxRows, + timeoutSeconds: timeoutSeconds + ) + + try await throwIfCancelled(context) + await context.progress.emit(progress: 0.8, total: 1.0, message: "Formatting result") + + let payload = JsonValue.fromLegacy(result) + + await context.progress.emit(progress: 1.0, total: 1.0, message: "Done") + return .json(payload) + } + + private func classifyAndAuthorize( + tier: QueryTier, + query: String, + connectionId: UUID, + meta: ToolConnectionMetadata, + services: MCPToolServices, + context: MCPRequestContext + ) throws { + switch tier { + case .destructive: + throw MCPProtocolError.forbidden( + reason: "Destructive queries (DROP, TRUNCATE, ALTER...DROP) cannot be executed via execute_query. Use the confirm_destructive_operation tool instead." + ) + case .write: + guard context.principal.scopes.contains(.toolsWrite) else { + throw MCPProtocolError.forbidden( + reason: "Principal lacks tools:write scope required for write queries" + ) + } + case .safe: + return + } + } + + private func throwIfCancelled(_ context: MCPRequestContext) async throws { + guard await context.cancellation.isCancelled() else { return } + throw MCPProtocolError( + code: JsonRpcErrorCode.requestCancelled, + message: "Cancelled", + httpStatus: .ok + ) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift new file mode 100644 index 000000000..0a9b5907f --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift @@ -0,0 +1,317 @@ +import Foundation +import os + +public struct ExportDataTool: MCPToolImplementation { + public static let name = "export_data" + public static let description = String( + localized: "Export query results or table data to CSV, JSON, or SQL" + ) + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string(String(localized: "UUID of the connection")) + ]), + "format": .object([ + "type": .string("string"), + "description": .string(String(localized: "Export format: csv, json, or sql")), + "enum": .array([.string("csv"), .string("json"), .string("sql")]) + ]), + "query": .object([ + "type": .string("string"), + "description": .string(String(localized: "SQL query to export results from")) + ]), + "tables": .object([ + "type": .string("array"), + "description": .string(String(localized: "Table names to export (alternative to query)")), + "items": .object(["type": .string("string")]) + ]), + "output_path": .object([ + "type": .string("string"), + "description": .string(String(localized: "File path inside the user's Downloads directory (returns inline data if omitted). Paths outside Downloads are rejected.")) + ]), + "max_rows": .object([ + "type": .string("integer"), + "description": .string(String(localized: "Maximum rows to export (default 50000)")) + ]) + ]), + "required": .array([.string("connection_id"), .string("format")]) + ]) + public static let requiredScopes: Set = [.toolsRead] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") + private static let allowedFormats: Set = ["csv", "json", "sql"] + private static let exportTableNamePattern = "^[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)*$" + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let format = try MCPArgumentDecoder.requireString(arguments, key: "format") + let query = MCPArgumentDecoder.optionalString(arguments, key: "query") + let tables = MCPArgumentDecoder.optionalStringArray(arguments, key: "tables") + let outputPath = MCPArgumentDecoder.optionalString(arguments, key: "output_path") + let maxRows = MCPArgumentDecoder.optionalInt( + arguments, + key: "max_rows", + default: 50_000, + clamp: 1...100_000 + ) ?? 50_000 + + guard Self.allowedFormats.contains(format) else { + throw MCPProtocolError.invalidParams( + detail: "Unsupported format: \(format). Must be csv, json, or sql" + ) + } + + guard query != nil || tables != nil else { + throw MCPProtocolError.invalidParams(detail: "Either 'query' or 'tables' must be provided") + } + + if let tables { + for table in tables { + try Self.validateExportTableName(table) + } + } + + if let outputPath { + _ = try Self.sandboxedDownloadsURL(for: outputPath) + } + + let meta = try await ToolConnectionMetadata.resolve(connectionId: connectionId) + var queries: [(label: String, sql: String)] = [] + + if let query { + try await services.authPolicy.checkSafeModeDialog( + sql: query, + connectionId: connectionId, + databaseType: meta.databaseType, + safeModeLevel: meta.safeModeLevel + ) + queries.append((label: "query", sql: query)) + } else if let tables { + let quoteIdentifier = Self.identifierQuoter(for: meta.databaseType) + for table in tables { + let quoted = try Self.quoteQualifiedIdentifier(table, quoter: quoteIdentifier) + let sql = "SELECT * FROM \(quoted) LIMIT \(maxRows)" + try await services.authPolicy.checkSafeModeDialog( + sql: sql, + connectionId: connectionId, + databaseType: meta.databaseType, + safeModeLevel: meta.safeModeLevel + ) + queries.append((label: table, sql: sql)) + } + } + + var exportResults: [JSONValue] = [] + var totalRowsExported = 0 + + for (label, sql) in queries { + let result = try await services.connectionBridge.executeQuery( + connectionId: connectionId, + query: sql, + maxRows: maxRows, + timeoutSeconds: 60 + ) + + guard let columns = result["columns"]?.arrayValue, + let rows = result["rows"]?.arrayValue + else { + throw MCPProtocolError.internalError(detail: "Unexpected query result structure") + } + + let columnNames = columns.compactMap(\.stringValue) + let formatted: String + + switch format { + case "csv": + formatted = Self.formatCSV(columns: columnNames, rows: rows) + case "json": + formatted = Self.formatJSON(columns: columnNames, rows: rows) + case "sql": + formatted = Self.formatSQL(table: label, columns: columnNames, rows: rows) + default: + formatted = Self.formatCSV(columns: columnNames, rows: rows) + } + + totalRowsExported += rows.count + + exportResults.append(.object([ + "label": .string(label), + "format": .string(format), + "row_count": result["row_count"] ?? .int(0), + "data": .string(formatted) + ])) + } + + if let outputPath { + let fileURL = try Self.sandboxedDownloadsURL(for: outputPath) + let fullContent: String + if exportResults.count == 1, + let data = exportResults.first?["data"]?.stringValue + { + fullContent = data + } else { + fullContent = exportResults + .compactMap { $0["data"]?.stringValue } + .joined(separator: "\n\n") + } + try fullContent.write(to: fileURL, atomically: true, encoding: .utf8) + + let response: JSONValue = .object([ + "path": .string(fileURL.path), + "rows_exported": .int(totalRowsExported) + ]) + return .json(JsonValue.fromLegacy(response)) + } + + let response: JSONValue + if exportResults.count == 1, let single = exportResults.first { + response = single + } else { + response = .object(["exports": .array(exportResults)]) + } + return .json(JsonValue.fromLegacy(response)) + } + + static func validateExportTableName(_ table: String) throws { + guard table.range(of: exportTableNamePattern, options: .regularExpression) != nil else { + throw MCPProtocolError.invalidParams( + detail: "Invalid table name: '\(table)'. Allowed characters: letters, digits, underscore, and '.' for schema-qualified names." + ) + } + } + + static func identifierQuoter(for databaseType: DatabaseType) -> (String) -> String { + if let dialect = try? resolveSQLDialect(for: databaseType) { + return quoteIdentifierFromDialect(dialect) + } + return { "\"\($0.replacingOccurrences(of: "\"", with: "\"\""))\"" } + } + + static func quoteQualifiedIdentifier(_ identifier: String, quoter: (String) -> String) throws -> String { + let segments = identifier.split(separator: ".", omittingEmptySubsequences: true) + let segmentsWithEmpty = identifier.split(separator: ".", omittingEmptySubsequences: false) + guard !segments.isEmpty, segments.count == segmentsWithEmpty.count else { + throw MCPProtocolError.invalidParams( + detail: "Invalid qualified identifier: '\(identifier)'. Empty components are not allowed." + ) + } + return segments.map { quoter(String($0)) }.joined(separator: ".") + } + + static func sandboxedDownloadsURL(for path: String) throws -> URL { + guard let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first else { + throw MCPProtocolError.invalidParams(detail: "Downloads directory is not available") + } + let downloadsRoot = downloads.standardizedFileURL.resolvingSymlinksInPath().path + let candidate = path.hasPrefix("/") ? URL(fileURLWithPath: path) : downloads.appendingPathComponent(path) + let resolvedPath = candidate.standardizedFileURL.resolvingSymlinksInPath().path + let prefix = downloadsRoot.hasSuffix("/") ? downloadsRoot : downloadsRoot + "/" + guard resolvedPath == downloadsRoot || resolvedPath.hasPrefix(prefix) else { + throw MCPProtocolError.invalidParams( + detail: "output_path must be inside the Downloads directory (\(downloadsRoot))" + ) + } + return URL(fileURLWithPath: resolvedPath) + } + + static func formatCSV(columns: [String], rows: [JSONValue]) -> String { + var lines: [String] = [] + lines.append(columns.map { escapeCSVField($0) }.joined(separator: ",")) + for row in rows { + guard let cells = row.arrayValue else { continue } + let line = cells.map { cell -> String in + switch cell { + case .string(let value): + return escapeCSVField(value) + case .null: + return "" + case .int(let value): + return String(value) + case .double(let value): + return String(value) + case .bool(let value): + return value ? "true" : "false" + default: + return escapeCSVField(encodeJSON(cell)) + } + } + lines.append(line.joined(separator: ",")) + } + return lines.joined(separator: "\n") + } + + static func escapeCSVField(_ field: String) -> String { + if field.contains(",") || field.contains("\"") || field.contains("\n") { + return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } + return field + } + + static func formatJSON(columns: [String], rows: [JSONValue]) -> String { + var objects: [JSONValue] = [] + for row in rows { + guard let cells = row.arrayValue else { continue } + var dict: [String: JSONValue] = [:] + for (index, column) in columns.enumerated() where index < cells.count { + dict[column] = cells[index] + } + objects.append(.object(dict)) + } + return encodeJSON(.array(objects)) + } + + static func formatSQL(table: String, columns: [String], rows: [JSONValue]) -> String { + guard !columns.isEmpty else { return "" } + var statements: [String] = [] + let escapedTable = "`\(table.replacingOccurrences(of: "`", with: "``"))`" + let escapedColumns = columns.map { "`\($0.replacingOccurrences(of: "`", with: "``"))`" } + let columnList = escapedColumns.joined(separator: ", ") + + for row in rows { + guard let cells = row.arrayValue else { continue } + let values = cells.map { cell -> String in + switch cell { + case .null: + return "NULL" + case .string(let value): + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + return "'\(escaped)'" + case .int(let value): + return String(value) + case .double(let value): + return String(value) + case .bool(let value): + return value ? "1" : "0" + default: + let escaped = encodeJSON(cell) + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + return "'\(escaped)'" + } + } + statements.append("INSERT INTO \(escapedTable) (\(columnList)) VALUES (\(values.joined(separator: ", ")));") + } + return statements.joined(separator: "\n") + } + + static func encodeJSON(_ value: JSONValue) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(value), + let string = String(data: data, encoding: .utf8) + else { + return "{}" + } + return string + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift b/TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift new file mode 100644 index 000000000..dbcc2af6c --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift @@ -0,0 +1,59 @@ +import AppKit +import Foundation + +public struct FocusQueryTabTool: MCPToolImplementation { + public static let name = "focus_query_tab" + public static let description = String(localized: "Focus an already-open tab by id (returned from list_recent_tabs).") + public static let requiredScopes: Set = [.toolsRead] + + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "tab_id": .object([ + "type": .string("string"), + "description": .string("UUID of the tab to focus") + ]) + ]), + "required": .array([.string("tab_id")]) + ]) + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let tabId = try MCPArgumentDecoder.requireUuid(arguments, key: "tab_id") + + let resolved: (windowId: UUID?, connectionId: UUID, raised: Bool)? = await MainActor.run { + for snapshot in MCPToolHandler.collectTabSnapshots() where snapshot.tabId == tabId { + guard let window = snapshot.window else { + return (windowId: snapshot.windowId, connectionId: snapshot.connectionId, raised: false) + } + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + return (windowId: snapshot.windowId, connectionId: snapshot.connectionId, raised: true) + } + return nil + } + + guard let resolved else { + throw MCPProtocolError.invalidParams(detail: "tab not found") + } + guard resolved.raised else { + throw MCPProtocolError.invalidParams(detail: "tab not found") + } + + var dict: [String: JsonValue] = [ + "status": .string("focused"), + "tab_id": .string(tabId.uuidString), + "connection_id": .string(resolved.connectionId.uuidString) + ] + if let windowId = resolved.windowId { + dict["window_id"] = .string(windowId.uuidString) + } + + return .json(.object(dict)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift b/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift new file mode 100644 index 000000000..0c67772ae --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift @@ -0,0 +1,30 @@ +import Foundation + +public struct GetConnectionStatusTool: MCPToolImplementation { + public static let name = "get_connection_status" + public static let description = String(localized: "Get detailed status of a database connection") + public static let requiredScopes: Set = [.toolsRead] + + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string("UUID of the connection") + ]) + ]), + "required": .array([.string("connection_id")]) + ]) + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let legacy = try await services.connectionBridge.getConnectionStatus(connectionId: connectionId) + return .json(JsonValue.fromLegacy(legacy)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift b/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift new file mode 100644 index 000000000..5fde55248 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift @@ -0,0 +1,45 @@ +import Foundation + +public struct GetTableDdlTool: MCPToolImplementation { + public static let name = "get_table_ddl" + public static let description = String(localized: "Get the CREATE TABLE DDL statement for a table") + public static let requiredScopes: Set = [.toolsRead] + + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string("UUID of the connection") + ]), + "table": .object([ + "type": .string("string"), + "description": .string("Table name") + ]), + "schema": .object([ + "type": .string("string"), + "description": .string("Schema name (uses current if omitted)") + ]) + ]), + "required": .array([.string("connection_id"), .string("table")]) + ]) + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let table = try MCPArgumentDecoder.requireString(arguments, key: "table") + let schema = MCPArgumentDecoder.optionalString(arguments, key: "schema") + + let legacy = try await services.connectionBridge.getTableDDL( + connectionId: connectionId, + table: table, + schema: schema + ) + return .json(JsonValue.fromLegacy(legacy)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/JsonValueLegacyBridge.swift b/TablePro/Core/MCP/Protocol/Tools/JsonValueLegacyBridge.swift new file mode 100644 index 000000000..e8735ffac --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/JsonValueLegacyBridge.swift @@ -0,0 +1,51 @@ +import Foundation + +enum JsonValueLegacyBridge { + static func toLegacy(_ value: JsonValue) -> JSONValue { + switch value { + case .null: + return .null + case .bool(let bool): + return .bool(bool) + case .int(let int): + return .int(int) + case .double(let double): + return .double(double) + case .string(let string): + return .string(string) + case .array(let array): + return .array(array.map { toLegacy($0) }) + case .object(let object): + return .object(object.mapValues { toLegacy($0) }) + } + } + + static func fromLegacy(_ value: JSONValue) -> JsonValue { + switch value { + case .null: + return .null + case .bool(let bool): + return .bool(bool) + case .int(let int): + return .int(int) + case .double(let double): + return .double(double) + case .string(let string): + return .string(string) + case .array(let array): + return .array(array.map { fromLegacy($0) }) + case .object(let object): + return .object(object.mapValues { fromLegacy($0) }) + } + } +} + +extension JsonValue { + func toLegacy() -> JSONValue { + JsonValueLegacyBridge.toLegacy(self) + } + + static func fromLegacy(_ value: JSONValue) -> JsonValue { + JsonValueLegacyBridge.fromLegacy(value) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift new file mode 100644 index 000000000..ba81be00b --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct ListConnectionsTool: MCPToolImplementation { + public static let name = "list_connections" + public static let description = String(localized: "List all saved database connections with their status") + public static let requiredScopes: Set = [.toolsRead] + + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([:]), + "required": .array([]) + ]) + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let legacy = await services.connectionBridge.listConnections() + return .json(JsonValue.fromLegacy(legacy)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift new file mode 100644 index 000000000..b20d10678 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift @@ -0,0 +1,30 @@ +import Foundation + +public struct ListDatabasesTool: MCPToolImplementation { + public static let name = "list_databases" + public static let description = String(localized: "List all databases on the server") + public static let requiredScopes: Set = [.toolsRead] + + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string("UUID of the connection") + ]) + ]), + "required": .array([.string("connection_id")]) + ]) + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let legacy = try await services.connectionBridge.listDatabases(connectionId: connectionId) + return .json(JsonValue.fromLegacy(legacy)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift new file mode 100644 index 000000000..896b4e748 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift @@ -0,0 +1,61 @@ +import Foundation + +public struct ListRecentTabsTool: MCPToolImplementation { + public static let name = "list_recent_tabs" + public static let description = String( + localized: "List currently open tabs across all TablePro windows. Returns connection, tab type, table name, and titles for each tab." + ) + public static let requiredScopes: Set = [.toolsRead] + + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "limit": .object([ + "type": .string("integer"), + "description": .string("Maximum number of tabs to return (default 20, max 500)") + ]) + ]), + "required": .array([]) + ]) + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let limit = MCPArgumentDecoder.optionalInt(arguments, key: "limit", default: 20, clamp: 1...500) ?? 20 + + let snapshots = await MainActor.run { MCPToolHandler.collectTabSnapshots() } + let blocked = await MainActor.run { MCPToolHandler.blockedExternalConnectionIds() } + let filtered = snapshots.filter { !blocked.contains($0.connectionId) } + let trimmed = Array(filtered.prefix(limit)) + + let payload: [JsonValue] = trimmed.map { snapshot in + var dict: [String: JsonValue] = [ + "connection_id": .string(snapshot.connectionId.uuidString), + "connection_name": .string(snapshot.connectionName), + "tab_id": .string(snapshot.tabId.uuidString), + "tab_type": .string(snapshot.tabType), + "display_title": .string(snapshot.displayTitle), + "is_active": .bool(snapshot.isActive) + ] + if let table = snapshot.tableName { + dict["table_name"] = .string(table) + } + if let database = snapshot.databaseName { + dict["database_name"] = .string(database) + } + if let schema = snapshot.schemaName { + dict["schema_name"] = .string(schema) + } + if let windowId = snapshot.windowId { + dict["window_id"] = .string(windowId.uuidString) + } + return .object(dict) + } + + return .json(.object(["tabs": .array(payload)])) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift new file mode 100644 index 000000000..9eba0d5f6 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct ListSchemasTool: MCPToolImplementation { + public static let name = "list_schemas" + public static let description = String(localized: "List schemas in a database") + public static let requiredScopes: Set = [.toolsRead] + + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string("UUID of the connection") + ]), + "database": .object([ + "type": .string("string"), + "description": .string("Database name (uses current if omitted)") + ]) + ]), + "required": .array([.string("connection_id")]) + ]) + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let database = MCPArgumentDecoder.optionalString(arguments, key: "database") + + if let database { + _ = try await services.connectionBridge.switchDatabase(connectionId: connectionId, database: database) + } + + let legacy = try await services.connectionBridge.listSchemas(connectionId: connectionId) + return .json(JsonValue.fromLegacy(legacy)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift new file mode 100644 index 000000000..9a2c7f97d --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift @@ -0,0 +1,56 @@ +import Foundation + +public struct ListTablesTool: MCPToolImplementation { + public static let name = "list_tables" + public static let description = String(localized: "List tables and views in a database") + public static let requiredScopes: Set = [.toolsRead] + + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string("UUID of the connection") + ]), + "database": .object([ + "type": .string("string"), + "description": .string("Database name (uses current if omitted)") + ]), + "schema": .object([ + "type": .string("string"), + "description": .string("Schema name (uses current if omitted)") + ]), + "include_row_counts": .object([ + "type": .string("boolean"), + "description": .string("Include approximate row counts (default false)") + ]) + ]), + "required": .array([.string("connection_id")]) + ]) + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let database = MCPArgumentDecoder.optionalString(arguments, key: "database") + let schema = MCPArgumentDecoder.optionalString(arguments, key: "schema") + let includeRowCounts = MCPArgumentDecoder.optionalBool(arguments, key: "include_row_counts", default: false) + + if let database { + _ = try await services.connectionBridge.switchDatabase(connectionId: connectionId, database: database) + } + if let schema { + _ = try await services.connectionBridge.switchSchema(connectionId: connectionId, schema: schema) + } + + let legacy = try await services.connectionBridge.listTables( + connectionId: connectionId, + includeRowCounts: includeRowCounts + ) + return .json(JsonValue.fromLegacy(legacy)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPArgumentDecoder.swift b/TablePro/Core/MCP/Protocol/Tools/MCPArgumentDecoder.swift new file mode 100644 index 000000000..c5fa6631c --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/MCPArgumentDecoder.swift @@ -0,0 +1,64 @@ +import Foundation + +enum MCPArgumentDecoder { + static func requireString(_ args: JsonValue, key: String) throws -> String { + guard case .string(let value) = args[key] else { + throw MCPProtocolError.invalidParams(detail: "Missing required parameter: \(key)") + } + return value + } + + static func optionalString(_ args: JsonValue, key: String) -> String? { + guard case .string(let value) = args[key] else { return nil } + return value + } + + static func requireUuid(_ args: JsonValue, key: String) throws -> UUID { + let raw = try requireString(args, key: key) + guard let uuid = UUID(uuidString: raw) else { + throw MCPProtocolError.invalidParams(detail: "Invalid UUID for parameter: \(key)") + } + return uuid + } + + static func optionalUuid(_ args: JsonValue, key: String) throws -> UUID? { + guard let raw = optionalString(args, key: key) else { return nil } + guard let uuid = UUID(uuidString: raw) else { + throw MCPProtocolError.invalidParams(detail: "Invalid UUID for parameter: \(key)") + } + return uuid + } + + static func requireInt(_ args: JsonValue, key: String) throws -> Int { + guard let value = args[key]?.intValue else { + throw MCPProtocolError.invalidParams(detail: "Missing required parameter: \(key)") + } + return value + } + + static func optionalInt( + _ args: JsonValue, + key: String, + default defaultValue: Int? = nil, + clamp: ClosedRange? = nil + ) -> Int? { + let raw = args[key]?.intValue + guard let raw else { return defaultValue } + guard let clamp else { return raw } + return min(max(raw, clamp.lowerBound), clamp.upperBound) + } + + static func optionalBool(_ args: JsonValue, key: String, default defaultValue: Bool = false) -> Bool { + args[key]?.boolValue ?? defaultValue + } + + static func optionalDouble(_ args: JsonValue, key: String) -> Double? { + args[key]?.doubleValue + } + + static func optionalStringArray(_ args: JsonValue, key: String) -> [String]? { + guard let array = args[key]?.arrayValue else { return nil } + let strings = array.compactMap { $0.stringValue } + return strings.isEmpty ? nil : strings + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift b/TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift new file mode 100644 index 000000000..9c652cedd --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift @@ -0,0 +1,63 @@ +import Foundation + +public protocol MCPToolImplementation: Sendable { + static var name: String { get } + static var description: String { get } + static var inputSchema: JsonValue { get } + static var requiredScopes: Set { get } + func call(arguments: JsonValue, context: MCPRequestContext, services: MCPToolServices) async throws -> MCPToolCallResult +} + +public extension MCPToolImplementation { + var name: String { Self.name } + var description: String { Self.description } + var inputSchema: JsonValue { Self.inputSchema } + var requiredScopes: Set { Self.requiredScopes } +} + +public struct MCPToolCallResult: Sendable { + public let content: [MCPToolContentItem] + public let isError: Bool + + public init(content: [MCPToolContentItem], isError: Bool = false) { + self.content = content + self.isError = isError + } + + public static func text(_ value: String, isError: Bool = false) -> MCPToolCallResult { + MCPToolCallResult(content: [.text(value)], isError: isError) + } + + public static func json(_ value: JsonValue, isError: Bool = false) -> MCPToolCallResult { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let encoded = (try? encoder.encode(value)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}" + return MCPToolCallResult(content: [.text(encoded)], isError: isError) + } +} + +public enum MCPToolContentItem: Sendable, Equatable { + case text(String) + + var asJsonValue: JsonValue { + switch self { + case .text(let value): + return .object([ + "type": .string("text"), + "text": .string(value) + ]) + } + } +} + +public extension MCPToolCallResult { + func asJsonValue() -> JsonValue { + var fields: [String: JsonValue] = [ + "content": .array(content.map { $0.asJsonValue }) + ] + if isError { + fields["isError"] = .bool(true) + } + return .object(fields) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift b/TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift new file mode 100644 index 000000000..233e12f04 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift @@ -0,0 +1,29 @@ +import Foundation + +public enum MCPToolRegistry { + public static let allTools: [any MCPToolImplementation] = [ + ListConnectionsTool(), + GetConnectionStatusTool(), + ListDatabasesTool(), + ListSchemasTool(), + ListTablesTool(), + DescribeTableTool(), + GetTableDdlTool(), + ListRecentTabsTool(), + SearchQueryHistoryTool(), + FocusQueryTabTool(), + ConnectTool(), + DisconnectTool(), + SwitchDatabaseTool(), + SwitchSchemaTool(), + ExecuteQueryTool(), + ExportDataTool(), + ConfirmDestructiveOperationTool(), + OpenTableTabTool(), + OpenConnectionWindowTool() + ] + + public static func tool(named name: String) -> (any MCPToolImplementation)? { + allTools.first { type(of: $0).name == name } + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPToolServices.swift b/TablePro/Core/MCP/Protocol/Tools/MCPToolServices.swift new file mode 100644 index 000000000..725f9f440 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/MCPToolServices.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct MCPToolServices: Sendable { + public let connectionBridge: MCPConnectionBridge + public let authPolicy: MCPAuthPolicy + + public init(connectionBridge: MCPConnectionBridge, authPolicy: MCPAuthPolicy) { + self.connectionBridge = connectionBridge + self.authPolicy = authPolicy + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift b/TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift new file mode 100644 index 000000000..5a2ab3c4c --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift @@ -0,0 +1,63 @@ +import AppKit +import Foundation +import os + +public struct OpenConnectionWindowTool: MCPToolImplementation { + public static let name = "open_connection_window" + public static let description = String( + localized: "Open a TablePro window for a saved connection (focuses if already open)." + ) + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string(String(localized: "UUID of the saved connection")) + ]) + ]), + "required": .array([.string("connection_id")]) + ]) + public static let requiredScopes: Set = [.toolsRead] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + try await ensureConnectionExists(connectionId) + + Self.logger.debug("open_connection_window invoked for connection \(connectionId.uuidString, privacy: .public)") + + let windowId = await MainActor.run { () -> UUID in + let payload = EditorTabPayload( + connectionId: connectionId, + tabType: .query, + intent: .restoreOrDefault + ) + WindowManager.shared.openTab(payload: payload) + NSApp.activate(ignoringOtherApps: true) + return payload.id + } + + let result: JsonValue = .object([ + "status": .string("opened"), + "connection_id": .string(connectionId.uuidString), + "window_id": .string(windowId.uuidString) + ]) + return .json(result) + } + + private func ensureConnectionExists(_ connectionId: UUID) async throws { + let exists = await MainActor.run { + ConnectionStorage.shared.loadConnections().contains { $0.id == connectionId } + } + guard exists else { + throw MCPProtocolError.invalidParams(detail: "Connection not found: \(connectionId.uuidString)") + } + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift b/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift new file mode 100644 index 000000000..91b7d6362 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift @@ -0,0 +1,83 @@ +import AppKit +import Foundation +import os + +public struct OpenTableTabTool: MCPToolImplementation { + public static let name = "open_table_tab" + public static let description = String( + localized: "Open a table tab in TablePro for the given connection." + ) + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string(String(localized: "UUID of the connection")) + ]), + "table_name": .object([ + "type": .string("string"), + "description": .string(String(localized: "Table name to open")) + ]), + "database_name": .object([ + "type": .string("string"), + "description": .string(String(localized: "Database name (uses connection's current database if omitted)")) + ]), + "schema_name": .object([ + "type": .string("string"), + "description": .string(String(localized: "Schema name (for multi-schema databases)")) + ]) + ]), + "required": .array([.string("connection_id"), .string("table_name")]) + ]) + public static let requiredScopes: Set = [.toolsRead] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let tableName = try MCPArgumentDecoder.requireString(arguments, key: "table_name") + let databaseName = MCPArgumentDecoder.optionalString(arguments, key: "database_name") + let schemaName = MCPArgumentDecoder.optionalString(arguments, key: "schema_name") + + try await ensureConnectionExists(connectionId) + + Self.logger.debug("open_table_tab invoked for connection \(connectionId.uuidString, privacy: .public)") + + let windowId = await MainActor.run { () -> UUID in + let payload = EditorTabPayload( + connectionId: connectionId, + tabType: .table, + tableName: tableName, + databaseName: databaseName, + schemaName: schemaName, + intent: .openContent + ) + WindowManager.shared.openTab(payload: payload) + NSApp.activate(ignoringOtherApps: true) + return payload.id + } + + let result: JsonValue = .object([ + "status": .string("opened"), + "connection_id": .string(connectionId.uuidString), + "table_name": .string(tableName), + "window_id": .string(windowId.uuidString) + ]) + return .json(result) + } + + private func ensureConnectionExists(_ connectionId: UUID) async throws { + let exists = await MainActor.run { + ConnectionStorage.shared.loadConnections().contains { $0.id == connectionId } + } + guard exists else { + throw MCPProtocolError.invalidParams(detail: "Connection not found: \(connectionId.uuidString)") + } + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift b/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift new file mode 100644 index 000000000..83cc150c0 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift @@ -0,0 +1,102 @@ +import Foundation + +public struct SearchQueryHistoryTool: MCPToolImplementation { + public static let name = "search_query_history" + public static let description = String( + localized: "Search saved query history. Returns matching entries with execution time, row count, and outcome." + ) + public static let requiredScopes: Set = [.toolsRead] + + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "query": .object([ + "type": .string("string"), + "description": .string("Search text (full-text matched against the query column)") + ]), + "connection_id": .object([ + "type": .string("string"), + "description": .string("Restrict to a specific connection (UUID, optional)") + ]), + "limit": .object([ + "type": .string("integer"), + "description": .string("Maximum number of entries to return (default 50, max 500)") + ]), + "since": .object([ + "type": .string("number"), + "description": .string("Earliest executed_at to include, Unix epoch seconds (inclusive, optional)") + ]), + "until": .object([ + "type": .string("number"), + "description": .string("Latest executed_at to include, Unix epoch seconds (inclusive, optional)") + ]) + ]), + "required": .array([.string("query")]) + ]) + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let query = try MCPArgumentDecoder.requireString(arguments, key: "query") + let connectionId = try MCPArgumentDecoder.optionalUuid(arguments, key: "connection_id") + let limit = MCPArgumentDecoder.optionalInt(arguments, key: "limit", default: 50, clamp: 1...500) ?? 50 + let since = MCPArgumentDecoder.optionalDouble(arguments, key: "since").map { Date(timeIntervalSince1970: $0) } + let until = MCPArgumentDecoder.optionalDouble(arguments, key: "until").map { Date(timeIntervalSince1970: $0) } + + if let since, let until, since > until { + throw MCPProtocolError.invalidParams(detail: "'since' must be less than or equal to 'until'") + } + + let blocked = await MainActor.run { MCPToolHandler.blockedExternalConnectionIds() } + + if let connectionId, blocked.contains(connectionId) { + throw MCPProtocolError.forbidden(reason: "External access is disabled for this connection") + } + + let allowlist: Set? + if connectionId != nil { + allowlist = nil + } else if blocked.isEmpty { + allowlist = nil + } else { + let allConnectionIds = await MainActor.run { + Set(ConnectionStorage.shared.loadConnections().map(\.id)) + } + allowlist = allConnectionIds.subtracting(blocked) + } + + let entries = await QueryHistoryStorage.shared.fetchHistory( + limit: limit, + offset: 0, + connectionId: connectionId, + searchText: query.isEmpty ? nil : query, + dateFilter: .all, + since: since, + until: until, + allowedConnectionIds: allowlist + ) + + let payload: [JsonValue] = entries.map { entry in + var dict: [String: JsonValue] = [ + "id": .string(entry.id.uuidString), + "query": .string(entry.query), + "connection_id": .string(entry.connectionId.uuidString), + "database_name": .string(entry.databaseName), + "executed_at": .double(entry.executedAt.timeIntervalSince1970), + "execution_time_ms": .double(entry.executionTime * 1_000), + "row_count": .int(entry.rowCount), + "was_successful": .bool(entry.wasSuccessful) + ] + if let error = entry.errorMessage { + dict["error_message"] = .string(error) + } + return .object(dict) + } + + return .json(.object(["entries": .array(payload)])) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift b/TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift new file mode 100644 index 000000000..d81b45599 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift @@ -0,0 +1,41 @@ +import Foundation +import os + +public struct SwitchDatabaseTool: MCPToolImplementation { + public static let name = "switch_database" + public static let description = String(localized: "Switch the active database on a connection") + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string(String(localized: "UUID of the connection")) + ]), + "database": .object([ + "type": .string("string"), + "description": .string(String(localized: "Database name to switch to")) + ]) + ]), + "required": .array([.string("connection_id"), .string("database")]) + ]) + public static let requiredScopes: Set = [.toolsWrite] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let database = try MCPArgumentDecoder.requireString(arguments, key: "database") + Self.logger.debug("switch_database tool invoked for connection \(connectionId.uuidString, privacy: .public)") + let legacy = try await services.connectionBridge.switchDatabase( + connectionId: connectionId, + database: database + ) + return .json(JsonValue.fromLegacy(legacy)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift b/TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift new file mode 100644 index 000000000..a4b529888 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift @@ -0,0 +1,41 @@ +import Foundation +import os + +public struct SwitchSchemaTool: MCPToolImplementation { + public static let name = "switch_schema" + public static let description = String(localized: "Switch the active schema on a connection") + public static let inputSchema: JsonValue = .object([ + "type": .string("object"), + "properties": .object([ + "connection_id": .object([ + "type": .string("string"), + "description": .string(String(localized: "UUID of the connection")) + ]), + "schema": .object([ + "type": .string("string"), + "description": .string(String(localized: "Schema name to switch to")) + ]) + ]), + "required": .array([.string("connection_id"), .string("schema")]) + ]) + public static let requiredScopes: Set = [.toolsWrite] + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") + + public init() {} + + public func call( + arguments: JsonValue, + context: MCPRequestContext, + services: MCPToolServices + ) async throws -> MCPToolCallResult { + let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") + let schema = try MCPArgumentDecoder.requireString(arguments, key: "schema") + Self.logger.debug("switch_schema tool invoked for connection \(connectionId.uuidString, privacy: .public)") + let legacy = try await services.connectionBridge.switchSchema( + connectionId: connectionId, + schema: schema + ) + return .json(JsonValue.fromLegacy(legacy)) + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift b/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift new file mode 100644 index 000000000..e14ad78ca --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift @@ -0,0 +1,28 @@ +import Foundation + +struct ToolConnectionMetadata { + let databaseType: DatabaseType + let safeModeLevel: SafeModeLevel + let databaseName: String + + static func resolve(connectionId: UUID) async throws -> ToolConnectionMetadata { + try await MainActor.run { + switch DatabaseManager.shared.connectionState(connectionId) { + case .live(_, let session): + return ToolConnectionMetadata( + databaseType: session.connection.type, + safeModeLevel: session.connection.safeModeLevel, + databaseName: session.activeDatabase + ) + case .stored(let conn): + return ToolConnectionMetadata( + databaseType: conn.type, + safeModeLevel: conn.safeModeLevel, + databaseName: conn.database + ) + case .unknown: + throw MCPProtocolError.invalidParams(detail: "Connection not found: \(connectionId.uuidString)") + } + } + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift new file mode 100644 index 000000000..30f853b19 --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift @@ -0,0 +1,46 @@ +import Foundation + +enum ToolQueryExecutor { + static func executeAndLog( + services: MCPToolServices, + query: String, + connectionId: UUID, + databaseName: String, + maxRows: Int, + timeoutSeconds: Int + ) async throws -> JSONValue { + let startTime = Date() + do { + let result = try await services.connectionBridge.executeQuery( + connectionId: connectionId, + query: query, + maxRows: maxRows, + timeoutSeconds: timeoutSeconds + ) + let elapsed = Date().timeIntervalSince(startTime) + let rowCount = result["row_count"]?.intValue ?? 0 + await services.authPolicy.logQuery( + sql: query, + connectionId: connectionId, + databaseName: databaseName, + executionTime: elapsed, + rowCount: rowCount, + wasSuccessful: true, + errorMessage: nil + ) + return result + } catch { + let elapsed = Date().timeIntervalSince(startTime) + await services.authPolicy.logQuery( + sql: query, + connectionId: connectionId, + databaseName: databaseName, + executionTime: elapsed, + rowCount: 0, + wasSuccessful: false, + errorMessage: error.localizedDescription + ) + throw error + } + } +} diff --git a/TableProTests/Core/MCP/Helpers/MCPProtocolHandlerTestSupport.swift b/TableProTests/Core/MCP/Helpers/MCPProtocolHandlerTestSupport.swift new file mode 100644 index 000000000..4bf2afd95 --- /dev/null +++ b/TableProTests/Core/MCP/Helpers/MCPProtocolHandlerTestSupport.swift @@ -0,0 +1,48 @@ +import Foundation +@testable import TablePro + +enum MCPProtocolHandlerTestSupport { + static func makeContext( + method: String, + params: JsonValue? = nil, + principalScopes: Set = [.toolsRead, .toolsWrite], + requestId: JsonRpcId = .number(1) + ) async -> MCPRequestContext { + let sessionStore = MCPSessionStore() + let progressSink = StubProgressSink() + let dispatcher = MCPProtocolDispatcher( + handlers: [], + sessionStore: sessionStore, + progressSink: progressSink, + clock: MCPSystemClock() + ) + + let session = MCPSession() + try? await session.transitionToReady() + + let principal = MCPProtocolTestSupport.makePrincipal(scopes: principalScopes) + let request = JsonRpcRequest(id: requestId, method: method, params: params) + let (exchange, _) = MCPProtocolTestSupport.makeExchange( + message: .request(request), + sessionId: session.id, + principal: principal + ) + + let cancellation = MCPCancellationToken() + let progress = MCPProgressEmitter( + progressToken: nil, + target: progressSink, + sessionId: session.id + ) + + return MCPRequestContext( + exchange: exchange, + session: session, + principal: principal, + dispatcher: dispatcher, + progress: progress, + cancellation: cancellation, + clock: MCPSystemClock() + ) + } +} diff --git a/TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift b/TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift new file mode 100644 index 000000000..17203d727 --- /dev/null +++ b/TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift @@ -0,0 +1,232 @@ +import Foundation +@testable import TablePro + +actor RecordingResponderSink: MCPResponderSink { + struct WriteJsonRecord { + let data: Data + let status: HttpStatus + let sessionId: MCPSessionId? + let extraHeaders: [(String, String)] + } + + private(set) var jsonWrites: [WriteJsonRecord] = [] + private(set) var acceptedCount: Int = 0 + private(set) var sseHeaderCount: Int = 0 + private(set) var sseFrames: [SseFrame] = [] + private(set) var closed: Bool = false + private(set) var sseRegistrations: [MCPSessionId] = [] + + private var continuation: CheckedContinuation? + private var completed: Bool = false + + func writeJson( + _ data: Data, + status: HttpStatus, + sessionId: MCPSessionId?, + extraHeaders: [(String, String)] + ) async { + jsonWrites.append(WriteJsonRecord( + data: data, + status: status, + sessionId: sessionId, + extraHeaders: extraHeaders + )) + } + + func writeAccepted() async { + acceptedCount += 1 + } + + func writeSseStreamHeaders(sessionId: MCPSessionId) async { + sseHeaderCount += 1 + } + + func writeSseFrame(_ frame: SseFrame) async { + sseFrames.append(frame) + } + + func closeConnection() async { + closed = true + if !completed { + completed = true + continuation?.resume() + continuation = nil + } + } + + func registerSseConnection(sessionId: MCPSessionId) async { + sseRegistrations.append(sessionId) + } + + func waitForCompletion() async { + if completed { return } + await withCheckedContinuation { (cont: CheckedContinuation) in + if completed { + cont.resume() + return + } + continuation = cont + } + } + + func firstJsonMessage() throws -> JsonRpcMessage? { + guard let record = jsonWrites.first else { return nil } + return try JsonRpcCodec.decode(record.data) + } +} + +actor StubProgressSink: MCPProgressSink { + private(set) var notifications: [(notification: JsonRpcNotification, sessionId: MCPSessionId)] = [] + + func sendNotification(_ notification: JsonRpcNotification, toSession sessionId: MCPSessionId) async { + notifications.append((notification, sessionId)) + } + + func count() -> Int { + notifications.count + } + + func methods() -> [String] { + notifications.map(\.notification.method) + } +} + +struct StubMethodHandler: MCPMethodHandler { + enum Behavior: Sendable { + case respondImmediately(JsonValue) + case throwProtocolError(MCPProtocolError) + case waitForCancellation + case slowSuccess(milliseconds: UInt64, JsonValue) + } + + static let method = "test/stub" + static let requiredScopes: Set = [] + static let allowedSessionStates: Set = [.uninitialized, .ready] + + let behavior: Behavior + let observedCancel: ObservedFlag + let started: ObservedFlag + + init(behavior: Behavior = .respondImmediately(.object(["ok": .bool(true)]))) { + self.behavior = behavior + self.observedCancel = ObservedFlag() + self.started = ObservedFlag() + } + + func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + await started.set() + switch behavior { + case .respondImmediately(let result): + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) + case .throwProtocolError(let error): + throw error + case .waitForCancellation: + while true { + if await context.cancellation.isCancelled() { + await observedCancel.set() + throw CancellationError() + } + try await Task.sleep(nanoseconds: 1_000_000) + } + case .slowSuccess(let ms, let result): + try await Task.sleep(nanoseconds: ms * 1_000_000) + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) + } + } +} + +actor ObservedFlag { + private var triggered: Bool = false + + func set() { + triggered = true + } + + func value() -> Bool { + triggered + } +} + +struct ConfigurableHandler: MCPMethodHandler { + static var method: String { T.method } + static var requiredScopes: Set { T.requiredScopes } + static var allowedSessionStates: Set { T.allowedSessionStates } + + let inner: T + + func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + try await inner.handle(params: params, context: context) + } +} + +struct ScopedToolsCallHandler: MCPMethodHandler { + static let method = "tools/call" + static let requiredScopes: Set = [.toolsWrite] + static let allowedSessionStates: Set = [.ready] + + func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: .object([:])) + } +} + +struct StubToolsListHandler: MCPMethodHandler { + static let method = "tools/list" + static let requiredScopes: Set = [] + static let allowedSessionStates: Set = [.ready] + + func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: .object(["tools": .array([])])) + } +} + +enum MCPProtocolTestSupport { + static func makePrincipal(scopes: Set = [.toolsRead, .toolsWrite]) -> MCPPrincipal { + MCPPrincipal( + tokenFingerprint: "test-fp", + scopes: scopes, + metadata: MCPPrincipalMetadata( + label: "test", + issuedAt: Date(timeIntervalSince1970: 1_700_000_000), + expiresAt: nil + ) + ) + } + + static func makeExchange( + message: JsonRpcMessage, + sessionId: MCPSessionId? = nil, + principal: MCPPrincipal? = nil, + receivedAt: Date = Date(timeIntervalSince1970: 1_700_000_000) + ) -> (MCPInboundExchange, RecordingResponderSink) { + let sink = RecordingResponderSink() + let requestId: JsonRpcId? + switch message { + case .request(let request): + requestId = request.id + default: + requestId = nil + } + let responder = MCPExchangeResponder(sink: sink, requestId: requestId) + let context = MCPInboundContext( + sessionId: sessionId, + principal: principal ?? makePrincipal(), + clientAddress: .loopback, + receivedAt: receivedAt, + mcpProtocolVersion: "2025-03-26" + ) + let exchange = MCPInboundExchange(message: message, context: context, responder: responder) + return (exchange, sink) + } + + static func makeRequest( + id: JsonRpcId = .number(1), + method: String, + params: JsonValue? = nil + ) -> JsonRpcMessage { + .request(JsonRpcRequest(id: id, method: method, params: params)) + } + + static func makeNotification(method: String, params: JsonValue? = nil) -> JsonRpcMessage { + .notification(JsonRpcNotification(method: method, params: params)) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift new file mode 100644 index 000000000..1bd368782 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift @@ -0,0 +1,135 @@ +import Foundation +@testable import TablePro +import XCTest + +final class InitializeHandlerTests: XCTestCase { + func testHandlerMethodIsInitialize() { + XCTAssertEqual(InitializeHandler.method, "initialize") + } + + func testHandlerRequiresNoScopes() { + XCTAssertTrue(InitializeHandler.requiredScopes.isEmpty) + } + + func testHandlerOnlyAllowsUninitializedState() { + XCTAssertEqual(InitializeHandler.allowedSessionStates, [.uninitialized]) + } + + func testHappyPathReturnsServerInfoAndCapabilities() async throws { + let context = try await makeContext() + let handler = InitializeHandler() + let params: JsonValue = .object([ + "protocolVersion": .string("2025-03-26"), + "clientInfo": .object([ + "name": .string("test-client"), + "version": .string("1.2.3") + ]), + "capabilities": .object([:]) + ]) + + let response = try await handler.handle(params: params, context: context) + + guard case .successResponse(let success) = response else { + XCTFail("Expected success response, got \(response)") + return + } + + guard case .object(let result) = success.result else { + XCTFail("Expected object result") + return + } + + XCTAssertEqual(result["protocolVersion"]?.stringValue, InitializeHandler.supportedProtocolVersion) + + guard let serverInfo = result["serverInfo"], case .object(let serverInfoDict) = serverInfo else { + XCTFail("Expected serverInfo object") + return + } + XCTAssertEqual(serverInfoDict["name"]?.stringValue, "tablepro") + XCTAssertNotNil(serverInfoDict["version"]?.stringValue) + + guard let capabilities = result["capabilities"], case .object(let capDict) = capabilities else { + XCTFail("Expected capabilities object") + return + } + XCTAssertNotNil(capDict["tools"]) + XCTAssertNotNil(capDict["resources"]) + XCTAssertNotNil(capDict["prompts"]) + XCTAssertNotNil(capDict["logging"]) + } + + func testRecordsClientInfoOnSession() async throws { + let context = try await makeContext() + let handler = InitializeHandler() + let params: JsonValue = .object([ + "protocolVersion": .string("2025-03-26"), + "clientInfo": .object([ + "name": .string("acme-cli"), + "version": .string("9.9.9") + ]), + "capabilities": .object(["x": .bool(true)]) + ]) + + _ = try await handler.handle(params: params, context: context) + + let info = await context.session.clientInfo + XCTAssertEqual(info?.name, "acme-cli") + XCTAssertEqual(info?.version, "9.9.9") + + let negotiated = await context.session.negotiatedProtocolVersion + XCTAssertEqual(negotiated, "2025-03-26") + + let recordedCapabilities = await context.session.clientCapabilities + XCTAssertEqual(recordedCapabilities, .object(["x": .bool(true)])) + } + + func testMissingClientInfoFallsBackToUnknown() async throws { + let context = try await makeContext() + let handler = InitializeHandler() + + _ = try await handler.handle(params: nil, context: context) + + let info = await context.session.clientInfo + XCTAssertEqual(info?.name, "unknown") + XCTAssertNil(info?.version) + } + + func testMissingProtocolVersionFallsBackToSupported() async throws { + let context = try await makeContext() + let handler = InitializeHandler() + + _ = try await handler.handle(params: .object([:]), context: context) + + let negotiated = await context.session.negotiatedProtocolVersion + XCTAssertEqual(negotiated, InitializeHandler.supportedProtocolVersion) + } + + private func makeContext() async throws -> MCPRequestContext { + let store = MCPSessionStore() + let session = try await store.create() + let sessionId = await session.id + let progressSink = StubProgressSink() + let dispatcher = MCPProtocolDispatcher( + handlers: [InitializeHandler()], + sessionStore: store, + progressSink: progressSink + ) + let request = MCPProtocolTestSupport.makeRequest(method: "initialize") + let (exchange, _) = MCPProtocolTestSupport.makeExchange(message: request, sessionId: sessionId) + let token = MCPCancellationToken() + let emitter = MCPProgressEmitter( + progressToken: nil, + target: progressSink, + sessionId: sessionId + ) + return MCPRequestContext( + exchange: exchange, + session: session, + principal: MCPProtocolTestSupport.makePrincipal(), + dispatcher: dispatcher, + progress: emitter, + cancellation: token, + clock: MCPSystemClock() + ) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Handlers/LoggingSetLevelHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/LoggingSetLevelHandlerTests.swift new file mode 100644 index 000000000..3cdfbf61a --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Handlers/LoggingSetLevelHandlerTests.swift @@ -0,0 +1,100 @@ +import Foundation +@testable import TablePro +import XCTest + +final class LoggingSetLevelHandlerTests: XCTestCase { + func testMethodIsLoggingSetLevel() { + XCTAssertEqual(LoggingSetLevelHandler.method, "logging/setLevel") + } + + func testRequiresNoScopes() { + XCTAssertTrue(LoggingSetLevelHandler.requiredScopes.isEmpty) + } + + func testAcceptsKnownLevels() async throws { + for level in ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"] { + let (handler, context) = try await makeContext() + let params: JsonValue = .object(["level": .string(level)]) + let response = try await handler.handle(params: params, context: context) + + guard case .successResponse(let success) = response else { + XCTFail("Expected success response for level \(level)") + return + } + XCTAssertEqual(success.result, .object([:])) + } + } + + func testAcceptsUppercaseLevels() async throws { + let (handler, context) = try await makeContext() + let params: JsonValue = .object(["level": .string("WARNING")]) + let response = try await handler.handle(params: params, context: context) + + guard case .successResponse = response else { + XCTFail("Expected success response for uppercase level") + return + } + } + + func testRejectsUnknownLevel() async throws { + let (handler, context) = try await makeContext() + let params: JsonValue = .object(["level": .string("verbose")]) + + do { + _ = try await handler.handle(params: params, context: context) + XCTFail("Expected MCPProtocolError") + } catch let error as MCPProtocolError { + XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + } + } + + func testRejectsMissingLevel() async throws { + let (handler, context) = try await makeContext() + + do { + _ = try await handler.handle(params: .object([:]), context: context) + XCTFail("Expected MCPProtocolError") + } catch let error as MCPProtocolError { + XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + } + } + + private func makeContext( + clock: any MCPClock = MCPSystemClock() + ) async throws -> (LoggingSetLevelHandler, MCPRequestContext) { + let store = MCPSessionStore(clock: clock) + let session = try await store.create() + try await session.transitionToReady() + let progressSink = StubProgressSink() + let dispatcher = MCPProtocolDispatcher( + handlers: [LoggingSetLevelHandler()], + sessionStore: store, + progressSink: progressSink, + clock: clock + ) + let request = MCPProtocolTestSupport.makeRequest(method: "logging/setLevel") + let principal = MCPProtocolTestSupport.makePrincipal(scopes: []) + let sessionId = await session.id + let (exchange, _) = MCPProtocolTestSupport.makeExchange( + message: request, + sessionId: sessionId, + principal: principal + ) + let token = MCPCancellationToken() + let emitter = MCPProgressEmitter( + progressToken: nil, + target: progressSink, + sessionId: sessionId + ) + let context = MCPRequestContext( + exchange: exchange, + session: session, + principal: principal, + dispatcher: dispatcher, + progress: emitter, + cancellation: token, + clock: clock + ) + return (LoggingSetLevelHandler(), context) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Handlers/PingHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/PingHandlerTests.swift new file mode 100644 index 000000000..62bf1bbaf --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Handlers/PingHandlerTests.swift @@ -0,0 +1,76 @@ +import Foundation +@testable import TablePro +import XCTest + +final class PingHandlerTests: XCTestCase { + func testHandlerMethodIsPing() { + XCTAssertEqual(PingHandler.method, "ping") + } + + func testHandlerRequiresNoScopes() { + XCTAssertTrue(PingHandler.requiredScopes.isEmpty) + } + + func testHandlerAllowsReadyAndUninitializedStates() { + XCTAssertTrue(PingHandler.allowedSessionStates.contains(.ready)) + } + + func testReturnsEmptyResult() async throws { + let (handler, context, _) = try await makeContext() + + let response = try await handler.handle(params: nil, context: context) + + guard case .successResponse(let success) = response else { + XCTFail("Expected success response, got \(response)") + return + } + XCTAssertEqual(success.result, .object([:])) + } + + func testTouchesSessionLastActivity() async throws { + let clock = MCPTestClock(start: Date(timeIntervalSince1970: 1_700_000_000)) + let (handler, context, session) = try await makeContext(clock: clock) + + let initialActivity = await session.lastActivityAt + await clock.advance(by: .seconds(120)) + + _ = try await handler.handle(params: nil, context: context) + + let after = await session.lastActivityAt + XCTAssertGreaterThan(after, initialActivity) + XCTAssertEqual(after, Date(timeIntervalSince1970: 1_700_000_000 + 120)) + } + + private func makeContext( + clock: any MCPClock = MCPSystemClock() + ) async throws -> (PingHandler, MCPRequestContext, MCPSession) { + let store = MCPSessionStore(clock: clock) + let session = try await store.create() + let sessionId = await session.id + let progressSink = StubProgressSink() + let dispatcher = MCPProtocolDispatcher( + handlers: [PingHandler()], + sessionStore: store, + progressSink: progressSink, + clock: clock + ) + let request = MCPProtocolTestSupport.makeRequest(method: "ping") + let (exchange, _) = MCPProtocolTestSupport.makeExchange(message: request, sessionId: sessionId) + let token = MCPCancellationToken() + let emitter = MCPProgressEmitter( + progressToken: nil, + target: progressSink, + sessionId: sessionId + ) + let context = MCPRequestContext( + exchange: exchange, + session: session, + principal: MCPProtocolTestSupport.makePrincipal(), + dispatcher: dispatcher, + progress: emitter, + cancellation: token, + clock: clock + ) + return (PingHandler(), context, session) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift new file mode 100644 index 000000000..915d247f7 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Handlers/PromptsListHandlerTests.swift @@ -0,0 +1,68 @@ +import Foundation +@testable import TablePro +import XCTest + +final class PromptsListHandlerTests: XCTestCase { + func testMethodIsPromptsList() { + XCTAssertEqual(PromptsListHandler.method, "prompts/list") + } + + func testRequiresNoScopes() { + XCTAssertTrue(PromptsListHandler.requiredScopes.isEmpty) + } + + func testAllowedInReadyState() { + XCTAssertEqual(PromptsListHandler.allowedSessionStates, [.ready]) + } + + func testReturnsEmptyList() async throws { + let (handler, context) = try await makeContext() + let response = try await handler.handle(params: nil, context: context) + + guard case .successResponse(let success) = response else { + XCTFail("Expected success response, got \(response)") + return + } + + XCTAssertEqual(success.result, .object(["prompts": .array([])])) + } + + private func makeContext( + clock: any MCPClock = MCPSystemClock() + ) async throws -> (PromptsListHandler, MCPRequestContext) { + let store = MCPSessionStore(clock: clock) + let session = try await store.create() + try await session.transitionToReady() + let progressSink = StubProgressSink() + let dispatcher = MCPProtocolDispatcher( + handlers: [PromptsListHandler()], + sessionStore: store, + progressSink: progressSink, + clock: clock + ) + let request = MCPProtocolTestSupport.makeRequest(method: "prompts/list") + let principal = MCPProtocolTestSupport.makePrincipal(scopes: []) + let sessionId = await session.id + let (exchange, _) = MCPProtocolTestSupport.makeExchange( + message: request, + sessionId: sessionId, + principal: principal + ) + let token = MCPCancellationToken() + let emitter = MCPProgressEmitter( + progressToken: nil, + target: progressSink, + sessionId: sessionId + ) + let context = MCPRequestContext( + exchange: exchange, + session: session, + principal: principal, + dispatcher: dispatcher, + progress: emitter, + cancellation: token, + clock: clock + ) + return (PromptsListHandler(), context) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift new file mode 100644 index 000000000..01364de1c --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesListHandlerTests.swift @@ -0,0 +1,91 @@ +import Foundation +@testable import TablePro +import XCTest + +final class ResourcesListHandlerTests: XCTestCase { + func testMethodIsResourcesList() { + XCTAssertEqual(ResourcesListHandler.method, "resources/list") + } + + func testRequiresResourcesReadScope() { + XCTAssertEqual(ResourcesListHandler.requiredScopes, [.resourcesRead]) + } + + func testAllowedInReadyState() { + XCTAssertEqual(ResourcesListHandler.allowedSessionStates, [.ready]) + } + + func testReturnsConnectionsResource() async throws { + let (handler, context) = try await makeContext() + let response = try await handler.handle(params: nil, context: context) + + guard case .successResponse(let success) = response else { + XCTFail("Expected success response, got \(response)") + return + } + + let resources = success.result["resources"]?.arrayValue + XCTAssertNotNil(resources) + let uris = resources?.compactMap { $0["uri"]?.stringValue } ?? [] + XCTAssertTrue(uris.contains("tablepro://connections")) + } + + func testEntriesIncludeNameAndMimeType() async throws { + let (handler, context) = try await makeContext() + let response = try await handler.handle(params: nil, context: context) + + guard case .successResponse(let success) = response, + let resources = success.result["resources"]?.arrayValue, + let connections = resources.first(where: { $0["uri"]?.stringValue == "tablepro://connections" }) + else { + XCTFail("Expected connections resource") + return + } + + XCTAssertNotNil(connections["name"]?.stringValue) + XCTAssertEqual(connections["mimeType"]?.stringValue, "application/json") + } + + private func makeContext( + clock: any MCPClock = MCPSystemClock() + ) async throws -> (ResourcesListHandler, MCPRequestContext) { + let store = MCPSessionStore(clock: clock) + let session = try await store.create() + try await session.transitionToReady() + let progressSink = StubProgressSink() + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + let dispatcher = MCPProtocolDispatcher( + handlers: [ResourcesListHandler(services: services)], + sessionStore: store, + progressSink: progressSink, + clock: clock + ) + let request = MCPProtocolTestSupport.makeRequest(method: "resources/list") + let principal = MCPProtocolTestSupport.makePrincipal(scopes: [.resourcesRead]) + let sessionId = await session.id + let (exchange, _) = MCPProtocolTestSupport.makeExchange( + message: request, + sessionId: sessionId, + principal: principal + ) + let token = MCPCancellationToken() + let emitter = MCPProgressEmitter( + progressToken: nil, + target: progressSink, + sessionId: sessionId + ) + let context = MCPRequestContext( + exchange: exchange, + session: session, + principal: principal, + dispatcher: dispatcher, + progress: emitter, + cancellation: token, + clock: clock + ) + return (ResourcesListHandler(services: services), context) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift new file mode 100644 index 000000000..9b789812a --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Handlers/ResourcesReadHandlerTests.swift @@ -0,0 +1,133 @@ +import Foundation +@testable import TablePro +import XCTest + +final class ResourcesReadHandlerTests: XCTestCase { + func testMethodIsResourcesRead() { + XCTAssertEqual(ResourcesReadHandler.method, "resources/read") + } + + func testRequiresResourcesReadScope() { + XCTAssertEqual(ResourcesReadHandler.requiredScopes, [.resourcesRead]) + } + + func testReadsConnectionsList() async throws { + let (handler, context) = try await makeContext() + let params: JsonValue = .object(["uri": .string("tablepro://connections")]) + + let response = try await handler.handle(params: params, context: context) + + guard case .successResponse(let success) = response else { + XCTFail("Expected success response, got \(response)") + return + } + + let contents = success.result["contents"]?.arrayValue + XCTAssertEqual(contents?.count, 1) + let entry = contents?.first + XCTAssertEqual(entry?["uri"]?.stringValue, "tablepro://connections") + XCTAssertEqual(entry?["mimeType"]?.stringValue, "application/json") + XCTAssertNotNil(entry?["text"]?.stringValue) + } + + func testMissingUriThrowsInvalidParams() async throws { + let (handler, context) = try await makeContext() + do { + _ = try await handler.handle(params: .object([:]), context: context) + XCTFail("Expected MCPProtocolError") + } catch let error as MCPProtocolError { + XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + } + } + + func testInvalidUriThrowsInvalidParams() async throws { + let (handler, context) = try await makeContext() + let params: JsonValue = .object(["uri": .string("not a url at all spaces")]) + + do { + _ = try await handler.handle(params: params, context: context) + XCTFail("Expected MCPProtocolError") + } catch let error as MCPProtocolError { + XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + } + } + + func testNonTableproSchemeRejected() async throws { + let (handler, context) = try await makeContext() + let params: JsonValue = .object(["uri": .string("https://example.com/foo")]) + + do { + _ = try await handler.handle(params: params, context: context) + XCTFail("Expected MCPProtocolError") + } catch let error as MCPProtocolError { + XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + } + } + + func testUnknownPathReturnsMethodNotFound() async throws { + let (handler, context) = try await makeContext() + let params: JsonValue = .object(["uri": .string("tablepro://unknown/resource")]) + + do { + _ = try await handler.handle(params: params, context: context) + XCTFail("Expected MCPProtocolError") + } catch let error as MCPProtocolError { + XCTAssertEqual(error.code, JsonRpcErrorCode.methodNotFound) + } + } + + func testInvalidUuidInSchemaPathRejected() async throws { + let (handler, context) = try await makeContext() + let params: JsonValue = .object(["uri": .string("tablepro://connections/not-a-uuid/schema")]) + + do { + _ = try await handler.handle(params: params, context: context) + XCTFail("Expected MCPProtocolError") + } catch let error as MCPProtocolError { + XCTAssertEqual(error.code, JsonRpcErrorCode.invalidParams) + } + } + + private func makeContext( + clock: any MCPClock = MCPSystemClock() + ) async throws -> (ResourcesReadHandler, MCPRequestContext) { + let store = MCPSessionStore(clock: clock) + let session = try await store.create() + try await session.transitionToReady() + let progressSink = StubProgressSink() + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + let dispatcher = MCPProtocolDispatcher( + handlers: [ResourcesReadHandler(services: services)], + sessionStore: store, + progressSink: progressSink, + clock: clock + ) + let request = MCPProtocolTestSupport.makeRequest(method: "resources/read") + let principal = MCPProtocolTestSupport.makePrincipal(scopes: [.resourcesRead]) + let sessionId = await session.id + let (exchange, _) = MCPProtocolTestSupport.makeExchange( + message: request, + sessionId: sessionId, + principal: principal + ) + let token = MCPCancellationToken() + let emitter = MCPProgressEmitter( + progressToken: nil, + target: progressSink, + sessionId: sessionId + ) + let context = MCPRequestContext( + exchange: exchange, + session: session, + principal: principal, + dispatcher: dispatcher, + progress: emitter, + cancellation: token, + clock: clock + ) + return (ResourcesReadHandler(services: services), context) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift new file mode 100644 index 000000000..10d1ecf4c --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift @@ -0,0 +1,118 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("ToolsCallHandler") +struct ToolsCallHandlerTests { + @Test("Unknown tool returns method not found") + func unknownTool() async throws { + let handler = makeHandler() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let params: JsonValue = .object([ + "name": .string("nonexistent_tool"), + "arguments": .object([:]) + ]) + + await #expect(throws: MCPProtocolError.self) { + _ = try await handler.handle(params: params, context: context) + } + } + + @Test("Missing tool name returns invalid params") + func missingToolName() async throws { + let handler = makeHandler() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let params: JsonValue = .object(["arguments": .object([:])]) + + await #expect(throws: MCPProtocolError.self) { + _ = try await handler.handle(params: params, context: context) + } + } + + @Test("Non-object params return invalid params") + func nonObjectParams() async throws { + let handler = makeHandler() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let params: JsonValue = .string("oops") + + await #expect(throws: MCPProtocolError.self) { + _ = try await handler.handle(params: params, context: context) + } + } + + @Test("Insufficient scope returns forbidden") + func insufficientScope() async throws { + let handler = makeHandler() + let context = await MCPProtocolHandlerTestSupport.makeContext( + method: "tools/call", + principalScopes: [] + ) + let params: JsonValue = .object([ + "name": .string("list_connections"), + "arguments": .object([:]) + ]) + + await #expect(throws: MCPProtocolError.self) { + _ = try await handler.handle(params: params, context: context) + } + } + + @Test("list_connections returns content array") + func listConnectionsHappyPath() async throws { + let handler = makeHandler() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let params: JsonValue = .object([ + "name": .string("list_connections"), + "arguments": .object([:]) + ]) + + let response = try await handler.handle(params: params, context: context) + guard case .successResponse(let success) = response else { + Issue.record("expected success, got \(response)") + return + } + let content = success.result["content"]?.arrayValue + #expect(content != nil) + #expect(content?.first?["type"]?.stringValue == "text") + } + + @Test("get_table_ddl with missing connection_id returns invalid params") + func getTableDdlMissingId() async throws { + let handler = makeHandler() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let params: JsonValue = .object([ + "name": .string("get_table_ddl"), + "arguments": .object([ + "table": .string("users") + ]) + ]) + + await #expect(throws: MCPProtocolError.self) { + _ = try await handler.handle(params: params, context: context) + } + } + + @Test("list_tables with malformed connection_id returns invalid params") + func listTablesMalformedId() async throws { + let handler = makeHandler() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let params: JsonValue = .object([ + "name": .string("list_tables"), + "arguments": .object([ + "connection_id": .string("not-a-uuid") + ]) + ]) + + await #expect(throws: MCPProtocolError.self) { + _ = try await handler.handle(params: params, context: context) + } + } + + private func makeHandler() -> ToolsCallHandler { + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + return ToolsCallHandler(services: services) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift new file mode 100644 index 000000000..9d6b0bccd --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift @@ -0,0 +1,80 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("ToolsListHandler") +struct ToolsListHandlerTests { + @Test("Lists all 19 tools from the registry") + func listsAllRegisteredTools() async throws { + let response = try await runToolsList() + let names = response["tools"]?.arrayValue?.compactMap { $0["name"]?.stringValue } ?? [] + + let expected: Set = [ + "list_connections", + "get_connection_status", + "list_databases", + "list_schemas", + "list_tables", + "describe_table", + "get_table_ddl", + "list_recent_tabs", + "search_query_history", + "focus_query_tab", + "connect", + "disconnect", + "switch_database", + "switch_schema", + "execute_query", + "export_data", + "confirm_destructive_operation", + "open_table_tab", + "open_connection_window" + ] + + #expect(Set(names) == expected) + #expect(names.count == 19) + } + + @Test("Each tool has name, description, and inputSchema") + func eachToolHasShapeFields() async throws { + let response = try await runToolsList() + let tools = response["tools"]?.arrayValue ?? [] + + for tool in tools { + let name = tool["name"]?.stringValue + let description = tool["description"]?.stringValue + let schema = tool["inputSchema"] + #expect(name != nil) + #expect(description?.isEmpty == false) + #expect(schema != nil) + } + } + + @Test("Each input schema is a JSON Schema object") + func inputSchemasAreObjects() async throws { + let response = try await runToolsList() + let tools = response["tools"]?.arrayValue ?? [] + + for tool in tools { + guard case .object(let schema) = tool["inputSchema"] else { + Issue.record("inputSchema not an object for tool \(tool["name"]?.stringValue ?? "?")") + continue + } + #expect(schema["type"]?.stringValue == "object") + #expect(schema["properties"] != nil) + #expect(schema["required"] != nil) + } + } + + private func runToolsList() async throws -> JsonValue { + let handler = ToolsListHandler() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/list") + let message = try await handler.handle(params: nil, context: context) + + guard case .successResponse(let response) = message else { + Issue.record("expected success response, got \(message)") + return .null + } + return response.result + } +} diff --git a/TableProTests/Core/MCP/Protocol/MCPArgumentDecoderTests.swift b/TableProTests/Core/MCP/Protocol/MCPArgumentDecoderTests.swift new file mode 100644 index 000000000..65cb1e6db --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/MCPArgumentDecoderTests.swift @@ -0,0 +1,167 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("MCP Argument Decoder") +struct MCPArgumentDecoderTests { + @Test("requireString returns string when present") + func requireStringPresent() throws { + let args: JsonValue = .object(["name": .string("hello")]) + let value = try MCPArgumentDecoder.requireString(args, key: "name") + #expect(value == "hello") + } + + @Test("requireString throws when missing") + func requireStringMissing() { + let args: JsonValue = .object([:]) + #expect(throws: MCPProtocolError.self) { + _ = try MCPArgumentDecoder.requireString(args, key: "name") + } + } + + @Test("requireString throws when wrong type") + func requireStringWrongType() { + let args: JsonValue = .object(["name": .int(5)]) + #expect(throws: MCPProtocolError.self) { + _ = try MCPArgumentDecoder.requireString(args, key: "name") + } + } + + @Test("optionalString returns nil when missing") + func optionalStringMissing() { + let args: JsonValue = .object([:]) + let value = MCPArgumentDecoder.optionalString(args, key: "name") + #expect(value == nil) + } + + @Test("optionalString returns value when present") + func optionalStringPresent() { + let args: JsonValue = .object(["name": .string("foo")]) + let value = MCPArgumentDecoder.optionalString(args, key: "name") + #expect(value == "foo") + } + + @Test("requireUuid parses a valid UUID string") + func requireUuidValid() throws { + let id = UUID() + let args: JsonValue = .object(["connection_id": .string(id.uuidString)]) + let value = try MCPArgumentDecoder.requireUuid(args, key: "connection_id") + #expect(value == id) + } + + @Test("requireUuid throws on malformed string") + func requireUuidInvalid() { + let args: JsonValue = .object(["connection_id": .string("not-a-uuid")]) + #expect(throws: MCPProtocolError.self) { + _ = try MCPArgumentDecoder.requireUuid(args, key: "connection_id") + } + } + + @Test("requireUuid throws when missing") + func requireUuidMissing() { + let args: JsonValue = .object([:]) + #expect(throws: MCPProtocolError.self) { + _ = try MCPArgumentDecoder.requireUuid(args, key: "connection_id") + } + } + + @Test("optionalUuid returns nil when missing") + func optionalUuidMissing() throws { + let args: JsonValue = .object([:]) + let value = try MCPArgumentDecoder.optionalUuid(args, key: "connection_id") + #expect(value == nil) + } + + @Test("optionalUuid throws on invalid value") + func optionalUuidInvalid() { + let args: JsonValue = .object(["connection_id": .string("bad")]) + #expect(throws: MCPProtocolError.self) { + _ = try MCPArgumentDecoder.optionalUuid(args, key: "connection_id") + } + } + + @Test("requireInt returns value") + func requireIntPresent() throws { + let args: JsonValue = .object(["count": .int(7)]) + let value = try MCPArgumentDecoder.requireInt(args, key: "count") + #expect(value == 7) + } + + @Test("requireInt throws when missing") + func requireIntMissing() { + let args: JsonValue = .object([:]) + #expect(throws: MCPProtocolError.self) { + _ = try MCPArgumentDecoder.requireInt(args, key: "count") + } + } + + @Test("optionalInt returns default when missing") + func optionalIntMissing() { + let args: JsonValue = .object([:]) + let value = MCPArgumentDecoder.optionalInt(args, key: "count", default: 42) + #expect(value == 42) + } + + @Test("optionalInt clamps within range") + func optionalIntClamps() { + let args: JsonValue = .object(["count": .int(1_000)]) + let value = MCPArgumentDecoder.optionalInt(args, key: "count", default: nil, clamp: 1...100) + #expect(value == 100) + } + + @Test("optionalInt clamps lower bound") + func optionalIntClampLower() { + let args: JsonValue = .object(["count": .int(-5)]) + let value = MCPArgumentDecoder.optionalInt(args, key: "count", default: nil, clamp: 1...100) + #expect(value == 1) + } + + @Test("optionalInt returns default when missing without clamp") + func optionalIntDefault() { + let args: JsonValue = .object([:]) + let value = MCPArgumentDecoder.optionalInt(args, key: "count", default: 5) + #expect(value == 5) + } + + @Test("optionalBool returns default when missing") + func optionalBoolDefault() { + let args: JsonValue = .object([:]) + #expect(MCPArgumentDecoder.optionalBool(args, key: "flag", default: true)) + #expect(!MCPArgumentDecoder.optionalBool(args, key: "flag", default: false)) + } + + @Test("optionalBool returns value when present") + func optionalBoolPresent() { + let args: JsonValue = .object(["flag": .bool(true)]) + #expect(MCPArgumentDecoder.optionalBool(args, key: "flag", default: false)) + } + + @Test("optionalDouble returns int as double") + func optionalDoubleFromInt() { + let args: JsonValue = .object(["value": .int(3)]) + #expect(MCPArgumentDecoder.optionalDouble(args, key: "value") == 3.0) + } + + @Test("optionalStringArray returns nil when missing") + func optionalStringArrayMissing() { + let args: JsonValue = .object([:]) + let value = MCPArgumentDecoder.optionalStringArray(args, key: "tables") + #expect(value == nil) + } + + @Test("optionalStringArray returns nil when empty") + func optionalStringArrayEmpty() { + let args: JsonValue = .object(["tables": .array([])]) + let value = MCPArgumentDecoder.optionalStringArray(args, key: "tables") + #expect(value == nil) + } + + @Test("optionalStringArray collects strings") + func optionalStringArrayCollects() { + let args: JsonValue = .object([ + "tables": .array([.string("a"), .string("b"), .int(3)]) + ]) + let value = MCPArgumentDecoder.optionalStringArray(args, key: "tables") + #expect(value == ["a", "b"]) + } +} diff --git a/TableProTests/Core/MCP/Protocol/MCPCancellationTokenTests.swift b/TableProTests/Core/MCP/Protocol/MCPCancellationTokenTests.swift new file mode 100644 index 000000000..0470062cd --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/MCPCancellationTokenTests.swift @@ -0,0 +1,117 @@ +import Foundation +@testable import TablePro +import XCTest + +final class MCPCancellationTokenTests: XCTestCase { + func testNewTokenIsNotCancelled() async { + let token = MCPCancellationToken() + let cancelled = await token.isCancelled() + XCTAssertFalse(cancelled) + } + + func testIsCancelledAfterCancel() async { + let token = MCPCancellationToken() + await token.cancel() + let cancelled = await token.isCancelled() + XCTAssertTrue(cancelled) + } + + func testOnCancelHandlerRunsWhenCancelFires() async { + let token = MCPCancellationToken() + let flag = ObservedFlag() + await token.onCancel { + await flag.set() + } + + let beforeCancel = await flag.value() + XCTAssertFalse(beforeCancel) + + await token.cancel() + + let afterCancel = await flag.value() + XCTAssertTrue(afterCancel) + } + + func testOnCancelRegisteredAfterCancelRunsImmediately() async { + let token = MCPCancellationToken() + await token.cancel() + + let flag = ObservedFlag() + await token.onCancel { + await flag.set() + } + + let value = await flag.value() + XCTAssertTrue(value) + } + + func testMultipleOnCancelHandlersAllInvoked() async { + let token = MCPCancellationToken() + let flagA = ObservedFlag() + let flagB = ObservedFlag() + let flagC = ObservedFlag() + + await token.onCancel { await flagA.set() } + await token.onCancel { await flagB.set() } + await token.onCancel { await flagC.set() } + + await token.cancel() + + let valueA = await flagA.value() + let valueB = await flagB.value() + let valueC = await flagC.value() + XCTAssertTrue(valueA) + XCTAssertTrue(valueB) + XCTAssertTrue(valueC) + } + + func testCancelTwiceIsIdempotent() async { + let token = MCPCancellationToken() + let counter = HandlerInvocationCounter() + await token.onCancel { + await counter.increment() + } + + await token.cancel() + await token.cancel() + + let count = await counter.value() + XCTAssertEqual(count, 1) + + let cancelled = await token.isCancelled() + XCTAssertTrue(cancelled) + } + + func testThrowIfCancelledThrowsAfterCancel() async { + let token = MCPCancellationToken() + await token.cancel() + do { + try await token.throwIfCancelled() + XCTFail("Expected CancellationError to be thrown") + } catch is CancellationError { + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testThrowIfCancelledDoesNotThrowWhenNotCancelled() async { + let token = MCPCancellationToken() + do { + try await token.throwIfCancelled() + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} + +private actor HandlerInvocationCounter { + private var invocations: Int = 0 + + func increment() { + invocations += 1 + } + + func value() -> Int { + invocations + } +} diff --git a/TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift b/TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift new file mode 100644 index 000000000..98cb1fcd7 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift @@ -0,0 +1,112 @@ +import Foundation +@testable import TablePro +import XCTest + +final class MCPInflightRegistryTests: XCTestCase { + func testCancelByRequestIdAndSessionIdCancelsToken() async { + let registry = MCPInflightRegistry() + let token = MCPCancellationToken() + let sessionId = MCPSessionId("session-1") + let requestId = JsonRpcId.number(42) + + await registry.register(requestId: requestId, sessionId: sessionId, token: token) + await registry.cancel(requestId: requestId, sessionId: sessionId) + + let cancelled = await token.isCancelled() + XCTAssertTrue(cancelled) + } + + func testRegisterSameKeyTwiceLatestWins() async { + let registry = MCPInflightRegistry() + let firstToken = MCPCancellationToken() + let secondToken = MCPCancellationToken() + let sessionId = MCPSessionId("session-2") + let requestId = JsonRpcId.string("req-x") + + await registry.register(requestId: requestId, sessionId: sessionId, token: firstToken) + await registry.register(requestId: requestId, sessionId: sessionId, token: secondToken) + + await registry.cancel(requestId: requestId, sessionId: sessionId) + + let firstCancelled = await firstToken.isCancelled() + let secondCancelled = await secondToken.isCancelled() + + XCTAssertFalse(firstCancelled) + XCTAssertTrue(secondCancelled) + } + + func testCancelNonexistentEntryIsNoop() async { + let registry = MCPInflightRegistry() + let sessionId = MCPSessionId("session-3") + let requestId = JsonRpcId.number(99) + + await registry.cancel(requestId: requestId, sessionId: sessionId) + let count = await registry.count() + XCTAssertEqual(count, 0) + } + + func testRemoveDropsEntryAndSubsequentCancelIsNoop() async { + let registry = MCPInflightRegistry() + let token = MCPCancellationToken() + let sessionId = MCPSessionId("session-4") + let requestId = JsonRpcId.number(7) + + await registry.register(requestId: requestId, sessionId: sessionId, token: token) + await registry.remove(requestId: requestId, sessionId: sessionId) + + let countAfterRemove = await registry.count() + XCTAssertEqual(countAfterRemove, 0) + + await registry.cancel(requestId: requestId, sessionId: sessionId) + let cancelled = await token.isCancelled() + XCTAssertFalse(cancelled) + } + + func testEntriesAreScopedBySessionId() async { + let registry = MCPInflightRegistry() + let tokenA = MCPCancellationToken() + let tokenB = MCPCancellationToken() + let sessionA = MCPSessionId("session-A") + let sessionB = MCPSessionId("session-B") + let requestId = JsonRpcId.number(1) + + await registry.register(requestId: requestId, sessionId: sessionA, token: tokenA) + await registry.register(requestId: requestId, sessionId: sessionB, token: tokenB) + + await registry.cancel(requestId: requestId, sessionId: sessionA) + + let cancelledA = await tokenA.isCancelled() + let cancelledB = await tokenB.isCancelled() + + XCTAssertTrue(cancelledA) + XCTAssertFalse(cancelledB) + } + + func testCountReflectsActiveRegistrations() async { + let registry = MCPInflightRegistry() + let session = MCPSessionId("session-count") + + await registry.register( + requestId: .number(1), + sessionId: session, + token: MCPCancellationToken() + ) + await registry.register( + requestId: .number(2), + sessionId: session, + token: MCPCancellationToken() + ) + await registry.register( + requestId: .number(3), + sessionId: session, + token: MCPCancellationToken() + ) + + let count = await registry.count() + XCTAssertEqual(count, 3) + + await registry.remove(requestId: .number(2), sessionId: session) + let countAfter = await registry.count() + XCTAssertEqual(countAfter, 2) + } +} diff --git a/TableProTests/Core/MCP/Protocol/MCPProgressEmitterTests.swift b/TableProTests/Core/MCP/Protocol/MCPProgressEmitterTests.swift new file mode 100644 index 000000000..a7830f8e1 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/MCPProgressEmitterTests.swift @@ -0,0 +1,160 @@ +import Foundation +@testable import TablePro +import XCTest + +final class MCPProgressEmitterTests: XCTestCase { + func testEmitWithoutProgressTokenIsNoop() async { + let sink = StubProgressSink() + let emitter = MCPProgressEmitter( + progressToken: nil, + target: sink, + sessionId: MCPSessionId("session-1") + ) + + await emitter.emit(progress: 0.5) + await emitter.emit(progress: 1.0, total: 1.0, message: "done") + + let count = await sink.count() + XCTAssertEqual(count, 0) + } + + func testEmitWithProgressTokenSendsNotification() async { + let sink = StubProgressSink() + let token = JsonValue.string("progress-token-1") + let emitter = MCPProgressEmitter( + progressToken: token, + target: sink, + sessionId: MCPSessionId("session-2") + ) + + await emitter.emit(progress: 0.42) + + let notifications = await sink.notifications + XCTAssertEqual(notifications.count, 1) + + guard let first = notifications.first else { + XCTFail("Expected at least one notification") + return + } + XCTAssertEqual(first.notification.method, "notifications/progress") + XCTAssertEqual(first.sessionId, MCPSessionId("session-2")) + + guard case .object(let params) = first.notification.params else { + XCTFail("Expected object params") + return + } + XCTAssertEqual(params["progressToken"], token) + XCTAssertEqual(params["progress"], .double(0.42)) + XCTAssertNil(params["total"]) + XCTAssertNil(params["message"]) + } + + func testEmitIncludesTotalAndMessageWhenProvided() async { + let sink = StubProgressSink() + let token = JsonValue.int(123) + let emitter = MCPProgressEmitter( + progressToken: token, + target: sink, + sessionId: MCPSessionId("session-3") + ) + + await emitter.emit(progress: 5.0, total: 10.0, message: "halfway there") + + let notifications = await sink.notifications + XCTAssertEqual(notifications.count, 1) + guard let first = notifications.first, + case .object(let params) = first.notification.params else { + XCTFail("Expected notification with object params") + return + } + XCTAssertEqual(params["progressToken"], token) + XCTAssertEqual(params["progress"], .double(5.0)) + XCTAssertEqual(params["total"], .double(10.0)) + XCTAssertEqual(params["message"], .string("halfway there")) + } + + func testMultipleEmitsQueueInOrder() async { + let sink = StubProgressSink() + let token = JsonValue.string("queue-token") + let emitter = MCPProgressEmitter( + progressToken: token, + target: sink, + sessionId: MCPSessionId("session-4") + ) + + await emitter.emit(progress: 0.1) + await emitter.emit(progress: 0.2) + await emitter.emit(progress: 0.3, message: "third") + + let notifications = await sink.notifications + XCTAssertEqual(notifications.count, 3) + + XCTAssertEqual(progressValue(in: notifications[0].notification), 0.1) + XCTAssertEqual(progressValue(in: notifications[1].notification), 0.2) + XCTAssertEqual(progressValue(in: notifications[2].notification), 0.3) + XCTAssertEqual(messageValue(in: notifications[2].notification), "third") + } + + func testEmitNotificationSendsCustomMethod() async { + let sink = StubProgressSink() + let emitter = MCPProgressEmitter( + progressToken: nil, + target: sink, + sessionId: MCPSessionId("session-5") + ) + + await emitter.emitNotification(method: "custom/event", params: .object(["x": .int(1)])) + + let notifications = await sink.notifications + XCTAssertEqual(notifications.count, 1) + XCTAssertEqual(notifications.first?.notification.method, "custom/event") + } + + func testHasProgressTokenReflectsState() async { + let sink = StubProgressSink() + let withToken = MCPProgressEmitter( + progressToken: .string("t"), + target: sink, + sessionId: MCPSessionId("s") + ) + let withoutToken = MCPProgressEmitter( + progressToken: nil, + target: sink, + sessionId: MCPSessionId("s") + ) + + let hasA = await withToken.hasProgressToken + let hasB = await withoutToken.hasProgressToken + + XCTAssertTrue(hasA) + XCTAssertFalse(hasB) + } + + func testExtractProgressTokenReadsMetaField() { + let params: JsonValue = .object([ + "_meta": .object(["progressToken": .string("abc-123")]) + ]) + + let token = MCPProgressEmitter.extractProgressToken(from: params) + XCTAssertEqual(token, .string("abc-123")) + } + + func testExtractProgressTokenReturnsNilWhenAbsent() { + let withoutMeta: JsonValue = .object(["foo": .int(1)]) + let withMetaButNoToken: JsonValue = .object(["_meta": .object([:])]) + + XCTAssertNil(MCPProgressEmitter.extractProgressToken(from: withoutMeta)) + XCTAssertNil(MCPProgressEmitter.extractProgressToken(from: withMetaButNoToken)) + XCTAssertNil(MCPProgressEmitter.extractProgressToken(from: nil)) + } + + private func progressValue(in notification: JsonRpcNotification) -> Double? { + guard case .object(let params) = notification.params else { return nil } + return params["progress"]?.doubleValue + } + + private func messageValue(in notification: JsonRpcNotification) -> String? { + guard case .object(let params) = notification.params else { return nil } + return params["message"]?.stringValue + } +} diff --git a/TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift b/TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift new file mode 100644 index 000000000..9d9184806 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/MCPProtocolDispatcherTests.swift @@ -0,0 +1,412 @@ +import Foundation +@testable import TablePro +import XCTest + +final class MCPProtocolDispatcherTests: XCTestCase { + func testMethodNotFoundReturnsErrorResponse() async throws { + let store = MCPSessionStore() + let session = try await store.create() + let sessionId = await session.id + let dispatcher = MCPProtocolDispatcher( + handlers: [InitializeHandler(), PingHandler()], + sessionStore: store, + progressSink: StubProgressSink() + ) + + let request = MCPProtocolTestSupport.makeRequest( + id: .number(1), + method: "unknown/method" + ) + let (exchange, sink) = MCPProtocolTestSupport.makeExchange( + message: request, + sessionId: sessionId + ) + + await dispatcher.dispatch(exchange) + await sink.waitForCompletion() + + let decoded = try await sink.firstJsonMessage() + guard case .errorResponse(let envelope) = decoded else { + XCTFail("Expected error response, got \(String(describing: decoded))") + return + } + XCTAssertEqual(envelope.error.code, JsonRpcErrorCode.methodNotFound) + XCTAssertEqual(envelope.id, .number(1)) + } + + func testUninitializedSessionRejectsNonInitializeMethods() async throws { + let store = MCPSessionStore() + let session = try await store.create() + let sessionId = await session.id + let dispatcher = MCPProtocolDispatcher( + handlers: [InitializeHandler(), StubToolsListHandler()], + sessionStore: store, + progressSink: StubProgressSink() + ) + + let request = MCPProtocolTestSupport.makeRequest( + id: .number(2), + method: "tools/list" + ) + let (exchange, sink) = MCPProtocolTestSupport.makeExchange( + message: request, + sessionId: sessionId + ) + + await dispatcher.dispatch(exchange) + await sink.waitForCompletion() + + let decoded = try await sink.firstJsonMessage() + guard case .errorResponse(let envelope) = decoded else { + XCTFail("Expected error response, got \(String(describing: decoded))") + return + } + XCTAssertEqual(envelope.error.code, JsonRpcErrorCode.invalidRequest) + } + + func testInitializeCreatesSessionAndNotificationTransitionsToReady() async throws { + let store = MCPSessionStore() + let dispatcher = MCPProtocolDispatcher( + handlers: [InitializeHandler()], + sessionStore: store, + progressSink: StubProgressSink() + ) + + let eventStream = await store.events + let collectorTask = Task { + for await event in eventStream { + if case .created(let id) = event { + return id + } + } + return nil + } + + let initRequest = MCPProtocolTestSupport.makeRequest( + id: .number(10), + method: "initialize", + params: .object([ + "protocolVersion": .string("2025-03-26"), + "clientInfo": .object(["name": .string("client-x")]), + "capabilities": .object([:]) + ]) + ) + let (initExchange, initSink) = MCPProtocolTestSupport.makeExchange(message: initRequest) + + await dispatcher.dispatch(initExchange) + await initSink.waitForCompletion() + + let initResponse = try await initSink.firstJsonMessage() + guard case .successResponse = initResponse else { + XCTFail("Expected success response, got \(String(describing: initResponse))") + return + } + + let sessionCount = await store.count() + XCTAssertEqual(sessionCount, 1) + + guard let createdId = await collectorTask.value else { + XCTFail("Expected the dispatcher to have created a session") + return + } + guard let session = await store.session(id: createdId) else { + XCTFail("Expected to find created session in store") + return + } + let sessionId = await session.id + + let stateAfterInitialize = await session.state + XCTAssertEqual(stateAfterInitialize, .initializing) + + let initializedNotification = MCPProtocolTestSupport.makeNotification( + method: "notifications/initialized" + ) + let (notifExchange, notifSink) = MCPProtocolTestSupport.makeExchange( + message: initializedNotification, + sessionId: sessionId + ) + + await dispatcher.dispatch(notifExchange) + await notifSink.waitForCompletion() + + let stateAfterNotification = await session.state + XCTAssertEqual(stateAfterNotification, .ready) + + let acceptedCount = await notifSink.acceptedCount + XCTAssertEqual(acceptedCount, 1) + } + + func testAuthScopeCheckRejectsInsufficientScopes() async throws { + let store = MCPSessionStore() + let session = try await store.create() + let sessionId = await session.id + try await session.transitionToReady() + + let dispatcher = MCPProtocolDispatcher( + handlers: [ScopedToolsCallHandler()], + sessionStore: store, + progressSink: StubProgressSink() + ) + + let principal = MCPProtocolTestSupport.makePrincipal(scopes: [.toolsRead]) + let request = MCPProtocolTestSupport.makeRequest( + id: .number(3), + method: "tools/call" + ) + let (exchange, sink) = MCPProtocolTestSupport.makeExchange( + message: request, + sessionId: sessionId, + principal: principal + ) + + await dispatcher.dispatch(exchange) + await sink.waitForCompletion() + + let decoded = try await sink.firstJsonMessage() + guard case .errorResponse(let envelope) = decoded else { + XCTFail("Expected error response, got \(String(describing: decoded))") + return + } + XCTAssertEqual(envelope.error.code, JsonRpcErrorCode.forbidden) + } + + func testCancellationFlowDeliversCancelledError() async throws { + let store = MCPSessionStore() + let session = try await store.create() + let sessionId = await session.id + try await session.transitionToReady() + + let stubHandler = StubMethodHandler(behavior: .waitForCancellation) + let dispatcher = MCPProtocolDispatcher( + handlers: [stubHandler], + sessionStore: store, + progressSink: StubProgressSink() + ) + let stubMethod = StubMethodHandler.method + + let requestId = JsonRpcId.number(7) + let request = MCPProtocolTestSupport.makeRequest(id: requestId, method: stubMethod) + let (exchange, sink) = MCPProtocolTestSupport.makeExchange( + message: request, + sessionId: sessionId + ) + + let dispatchTask = Task { + await dispatcher.dispatch(exchange) + } + + try await waitUntil(timeoutMs: 2_000) { + await stubHandler.started.value() + } + + let cancelNotification = MCPProtocolTestSupport.makeNotification( + method: "notifications/cancelled", + params: .object(["requestId": .int(7)]) + ) + let (cancelExchange, cancelSink) = MCPProtocolTestSupport.makeExchange( + message: cancelNotification, + sessionId: sessionId + ) + + await dispatcher.dispatch(cancelExchange) + await cancelSink.waitForCompletion() + + await dispatchTask.value + await sink.waitForCompletion() + + let decoded = try await sink.firstJsonMessage() + guard case .errorResponse(let envelope) = decoded else { + XCTFail("Expected error response, got \(String(describing: decoded))") + return + } + XCTAssertEqual(envelope.error.code, JsonRpcErrorCode.requestCancelled) + + let observed = await stubHandler.observedCancel.value() + XCTAssertTrue(observed) + } + + func testInboundResponsesAreIgnored() async throws { + let store = MCPSessionStore() + let dispatcher = MCPProtocolDispatcher( + handlers: [PingHandler()], + sessionStore: store, + progressSink: StubProgressSink() + ) + + let response = JsonRpcMessage.successResponse( + JsonRpcSuccessResponse(id: .number(99), result: .object([:])) + ) + let (exchange, sink) = MCPProtocolTestSupport.makeExchange(message: response) + + await dispatcher.dispatch(exchange) + await sink.waitForCompletion() + + let acceptedCount = await sink.acceptedCount + XCTAssertEqual(acceptedCount, 1) + let jsonWrites = await sink.jsonWrites + XCTAssertTrue(jsonWrites.isEmpty) + } + + func testNotificationInitializedTransitionsSessionWithoutResponse() async throws { + let store = MCPSessionStore() + let session = try await store.create() + let sessionId = await session.id + let dispatcher = MCPProtocolDispatcher( + handlers: [], + sessionStore: store, + progressSink: StubProgressSink() + ) + + let stateBefore = await session.state + XCTAssertEqual(stateBefore, .initializing) + + let notification = MCPProtocolTestSupport.makeNotification( + method: "notifications/initialized" + ) + let (exchange, sink) = MCPProtocolTestSupport.makeExchange( + message: notification, + sessionId: sessionId + ) + + await dispatcher.dispatch(exchange) + await sink.waitForCompletion() + + let stateAfter = await session.state + XCTAssertEqual(stateAfter, .ready) + + let acceptedCount = await sink.acceptedCount + XCTAssertEqual(acceptedCount, 1) + let writes = await sink.jsonWrites + XCTAssertTrue(writes.isEmpty) + } + + func testConcurrentRequestsInSameSessionAllComplete() async throws { + let store = MCPSessionStore() + let session = try await store.create() + let sessionId = await session.id + try await session.transitionToReady() + + let dispatcher = MCPProtocolDispatcher( + handlers: [PingHandler()], + sessionStore: store, + progressSink: StubProgressSink() + ) + + let count = 5 + var sinks: [RecordingResponderSink] = [] + sinks.reserveCapacity(count) + + await withTaskGroup(of: RecordingResponderSink.self) { group in + for index in 0..() + for sink in sinks { + let decoded = try await sink.firstJsonMessage() + guard case .successResponse(let success) = decoded else { + XCTFail("Expected success response, got \(String(describing: decoded))") + return + } + guard case .number(let value) = success.id else { + XCTFail("Expected numeric id, got \(success.id)") + return + } + seenIds.insert(value) + } + XCTAssertEqual(seenIds, Set((1...count).map { Int64($0) })) + } + + func testHandlerThrowingProtocolErrorYieldsErrorResponse() async throws { + let store = MCPSessionStore() + let session = try await store.create() + let sessionId = await session.id + try await session.transitionToReady() + + let stubError = MCPProtocolError.invalidParams(detail: "bad shape") + let handler = StubMethodHandler(behavior: .throwProtocolError(stubError)) + let dispatcher = MCPProtocolDispatcher( + handlers: [handler], + sessionStore: store, + progressSink: StubProgressSink() + ) + + let request = MCPProtocolTestSupport.makeRequest( + id: .number(11), + method: StubMethodHandler.method + ) + let (exchange, sink) = MCPProtocolTestSupport.makeExchange( + message: request, + sessionId: sessionId + ) + + await dispatcher.dispatch(exchange) + await sink.waitForCompletion() + + let decoded = try await sink.firstJsonMessage() + guard case .errorResponse(let envelope) = decoded else { + XCTFail("Expected error response, got \(String(describing: decoded))") + return + } + XCTAssertEqual(envelope.error.code, JsonRpcErrorCode.invalidParams) + } + + func testRequestWithoutSessionIdAndNonInitializeMethodFails() async throws { + let store = MCPSessionStore() + let dispatcher = MCPProtocolDispatcher( + handlers: [PingHandler()], + sessionStore: store, + progressSink: StubProgressSink() + ) + + let request = MCPProtocolTestSupport.makeRequest( + id: .number(20), + method: "ping" + ) + let (exchange, sink) = MCPProtocolTestSupport.makeExchange( + message: request, + sessionId: nil + ) + + await dispatcher.dispatch(exchange) + await sink.waitForCompletion() + + let decoded = try await sink.firstJsonMessage() + guard case .errorResponse(let envelope) = decoded else { + XCTFail("Expected error response, got \(String(describing: decoded))") + return + } + XCTAssertEqual(envelope.error.code, JsonRpcErrorCode.sessionNotFound) + } + + private func waitUntil( + timeoutMs: UInt64, + _ predicate: @Sendable () async -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000.0) + while Date() < deadline { + if await predicate() { return } + try await Task.sleep(nanoseconds: 10_000_000) + } + if await predicate() { return } + XCTFail("Timed out waiting for condition after \(timeoutMs)ms") + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationToolTests.swift new file mode 100644 index 000000000..30c8c2600 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationToolTests.swift @@ -0,0 +1,91 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("ConfirmDestructiveOperationTool") +struct ConfirmDestructiveOperationToolTests { + @Test("Tool requires write scope") + func requiresWriteScope() { + #expect(ConfirmDestructiveOperationTool.requiredScopes == [.toolsWrite]) + #expect(ConfirmDestructiveOperationTool.name == "confirm_destructive_operation") + } + + @Test("Wrong confirmation phrase returns invalidParams") + func wrongConfirmationPhrase() async throws { + let tool = ConfirmDestructiveOperationTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + let connectionId = UUID() + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string(connectionId.uuidString), + "query": .string("DROP TABLE users"), + "confirmation_phrase": .string("yes do it") + ]), + context: context, + services: services + ) + } + } + + @Test("Missing query returns invalidParams") + func missingQuery() async throws { + let tool = ConfirmDestructiveOperationTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string(UUID().uuidString), + "confirmation_phrase": .string("I understand this is irreversible") + ]), + context: context, + services: services + ) + } + } + + @Test("Multi-statement query is rejected before connection lookup") + func multiStatementRejected() async throws { + let tool = ConfirmDestructiveOperationTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + let connectionId = UUID() + + do { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string(connectionId.uuidString), + "query": .string("DROP TABLE users; DROP TABLE other"), + "confirmation_phrase": .string("I understand this is irreversible") + ]), + context: context, + services: services + ) + Issue.record("Expected MCPProtocolError for multi-statement query") + } catch let error as MCPProtocolError { + #expect(error.code == JsonRpcErrorCode.invalidParams) + } + } + + @Test("Tool input schema declares required fields") + func inputSchemaRequiredFields() { + let schema = ConfirmDestructiveOperationTool.inputSchema + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required.contains("connection_id")) + #expect(required.contains("query")) + #expect(required.contains("confirmation_phrase")) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/ConnectToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ConnectToolTests.swift new file mode 100644 index 000000000..be9518fa0 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/ConnectToolTests.swift @@ -0,0 +1,54 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("ConnectTool") +struct ConnectToolTests { + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = ConnectTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([:]), + context: context, + services: services + ) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = ConnectTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string("not-a-uuid") + ]), + context: context, + services: services + ) + } + } + + @Test("Tool exposes expected metadata") + func metadata() { + #expect(ConnectTool.name == "connect") + #expect(ConnectTool.requiredScopes == [.toolsRead]) + let schema = ConnectTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) + #expect(required == ["connection_id"]) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift new file mode 100644 index 000000000..95bc91522 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift @@ -0,0 +1,192 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("ExecuteQueryTool") +struct ExecuteQueryToolTests { + @Test("Tool exposes correct metadata") + func metadata() { + #expect(ExecuteQueryTool.name == "execute_query") + #expect(ExecuteQueryTool.requiredScopes == [.toolsRead]) + let schema = ExecuteQueryTool.inputSchema + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required.contains("connection_id")) + #expect(required.contains("query")) + } + + @Test("Multi-statement query is rejected before connection lookup") + func multiStatementRejected() async throws { + let tool = ExecuteQueryTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + + do { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string(UUID().uuidString), + "query": .string("SELECT 1; SELECT 2") + ]), + context: context, + services: services + ) + Issue.record("Expected MCPProtocolError for multi-statement query") + } catch let error as MCPProtocolError { + #expect(error.code == JsonRpcErrorCode.invalidParams) + } + } + + @Test("Query exceeding 100KB is rejected with invalidParams") + func queryTooLargeRejected() async throws { + let tool = ExecuteQueryTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + let oversized = String(repeating: "a", count: 102_401) + + do { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string(UUID().uuidString), + "query": .string(oversized) + ]), + context: context, + services: services + ) + Issue.record("Expected oversized query to be rejected") + } catch let error as MCPProtocolError { + #expect(error.code == JsonRpcErrorCode.invalidParams) + } + } + + @Test("Cancellation propagates as requestCancelled") + func cancellationPropagates() async throws { + let tool = ExecuteQueryTool() + let progressSink = StubProgressSink() + let context = await ExecuteQueryToolTestContext.make( + progressToken: nil, + progressSink: progressSink + ) + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + + await context.cancellation.cancel() + + do { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string(UUID().uuidString), + "query": .string("SELECT 1") + ]), + context: context, + services: services + ) + Issue.record("Expected cancelled error") + } catch let error as MCPProtocolError { + #expect(error.code == JsonRpcErrorCode.requestCancelled) + } + } + + @Test("Progress notifications fire when progressToken is set") + func progressEmittedWhenTokenPresent() async throws { + let tool = ExecuteQueryTool() + let progressSink = StubProgressSink() + let context = await ExecuteQueryToolTestContext.make( + progressToken: .string("progress-1"), + progressSink: progressSink + ) + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + + _ = try? await tool.call( + arguments: .object([ + "connection_id": .string(UUID().uuidString), + "query": .string("SELECT 1") + ]), + context: context, + services: services + ) + + let methods = await progressSink.methods() + #expect(methods.allSatisfy { $0 == "notifications/progress" }) + #expect(methods.count >= 1) + } + + @Test("Progress notifications are skipped when no progressToken") + func progressSkippedWithoutToken() async throws { + let tool = ExecuteQueryTool() + let progressSink = StubProgressSink() + let context = await ExecuteQueryToolTestContext.make( + progressToken: nil, + progressSink: progressSink + ) + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + + _ = try? await tool.call( + arguments: .object([ + "connection_id": .string(UUID().uuidString), + "query": .string("SELECT 1") + ]), + context: context, + services: services + ) + + let count = await progressSink.count() + #expect(count == 0) + } +} + +enum ExecuteQueryToolTestContext { + static func make( + progressToken: JsonValue?, + progressSink: StubProgressSink + ) async -> MCPRequestContext { + let sessionStore = MCPSessionStore() + let dispatcher = MCPProtocolDispatcher( + handlers: [], + sessionStore: sessionStore, + progressSink: progressSink, + clock: MCPSystemClock() + ) + + let session = MCPSession() + try? await session.transitionToReady() + let resolvedSessionId = await session.id + + let principal = MCPProtocolTestSupport.makePrincipal(scopes: [.toolsRead, .toolsWrite]) + let request = JsonRpcRequest(id: .number(1), method: "tools/call", params: nil) + let (exchange, _) = MCPProtocolTestSupport.makeExchange( + message: .request(request), + sessionId: resolvedSessionId, + principal: principal + ) + + let cancellation = MCPCancellationToken() + let progress = MCPProgressEmitter( + progressToken: progressToken, + target: progressSink, + sessionId: resolvedSessionId + ) + + return MCPRequestContext( + exchange: exchange, + session: session, + principal: principal, + dispatcher: dispatcher, + progress: progress, + cancellation: cancellation, + clock: MCPSystemClock() + ) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/SwitchDatabaseToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/SwitchDatabaseToolTests.swift new file mode 100644 index 000000000..4852ddc20 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/SwitchDatabaseToolTests.swift @@ -0,0 +1,58 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("SwitchDatabaseTool") +struct SwitchDatabaseToolTests { + @Test("Tool requires write scope") + func requiresWriteScope() { + #expect(SwitchDatabaseTool.requiredScopes == [.toolsWrite]) + #expect(SwitchDatabaseTool.name == "switch_database") + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = SwitchDatabaseTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["database": .string("foo")]), + context: context, + services: services + ) + } + } + + @Test("Missing database returns invalidParams") + func missingDatabase() async throws { + let tool = SwitchDatabaseTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string(UUID().uuidString) + ]), + context: context, + services: services + ) + } + } + + @Test("Schema lists both required parameters") + func schemaRequiredFields() { + let schema = SwitchDatabaseTool.inputSchema + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required.contains("connection_id")) + #expect(required.contains("database")) + } +} From a53a82e378259ec28c06c479c30d1895107e4f8f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 14:17:21 +0700 Subject: [PATCH 04/54] refactor(mcp): phase 5 bridge rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tablepro-mcp binary is now a thin composition root over the new transport types. BridgeMain wires up MCPStdioMessageTransport (host side) and MCPStreamableHttpClientTransport (upstream side); BridgeProxy forwards JsonRpcMessage objects between them with a TaskGroup so both directions run concurrently. Errors land in os_log via MCPCompositeBridgeLogger and never leak to stdout — the host-facing transport guarantees only valid JSON-RPC bytes reach Claude Desktop's stdin. Handshake module now uses Duration/ContinuousClock instead of TimeInterval math, and properly distinguishes 'file missing' from 'process not running' when deciding whether to relaunch. mcp-server target's pbxproj exception list expanded to include the Wire/ and Transport/ files the bridge depends on; main.swift renamed to BridgeMain.swift since @main on a struct is incompatible with main.swift filename. Old MCPBridgeProxy.swift deleted. --- TablePro.xcodeproj/project.pbxproj | 29 ++- TablePro/CLI/BridgeMain.swift | 58 +++++ TablePro/CLI/BridgeProxy.swift | 43 ++++ TablePro/CLI/Handshake.swift | 105 +++++++++ TablePro/CLI/MCPBridgeProxy.swift | 328 ----------------------------- TablePro/CLI/main.swift | 9 - 6 files changed, 231 insertions(+), 341 deletions(-) create mode 100644 TablePro/CLI/BridgeMain.swift create mode 100644 TablePro/CLI/BridgeProxy.swift create mode 100644 TablePro/CLI/Handshake.swift delete mode 100644 TablePro/CLI/MCPBridgeProxy.swift delete mode 100644 TablePro/CLI/main.swift diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index fae3e53a5..b75c76c36 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -318,8 +318,27 @@ 5A32BC082F9D5FC900BAEB5F /* Exceptions for "TablePro" folder in "mcp-server" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - CLI/main.swift, - CLI/MCPBridgeProxy.swift, + + CLI/BridgeMain.swift, + CLI/Handshake.swift, + CLI/BridgeProxy.swift, + Core/MCP/Wire/JsonValue.swift, + Core/MCP/Wire/JsonRpcId.swift, + Core/MCP/Wire/JsonRpcVersion.swift, + Core/MCP/Wire/JsonRpcErrorCode.swift, + Core/MCP/Wire/JsonRpcError.swift, + Core/MCP/Wire/JsonRpcMessage.swift, + Core/MCP/Wire/JsonRpcCodec.swift, + Core/MCP/Wire/SseFrame.swift, + Core/MCP/Wire/SseEncoder.swift, + Core/MCP/Wire/SseDecoder.swift, + Core/MCP/Wire/HttpRequestHead.swift, + Core/MCP/Wire/HttpResponseHead.swift, + Core/MCP/Transport/MCPMessageTransport.swift, + Core/MCP/Transport/MCPProtocolError.swift, + Core/MCP/Transport/MCPStdioMessageTransport.swift, + Core/MCP/Transport/MCPStreamableHttpClientTransport.swift, + Core/MCP/Transport/MCPBridgeLogger.swift, ); target = 5A32BBFF2F9D5F1300BAEB5F /* mcp-server */; }; @@ -459,8 +478,10 @@ 5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - CLI/main.swift, - CLI/MCPBridgeProxy.swift, + + CLI/BridgeMain.swift, + CLI/Handshake.swift, + CLI/BridgeProxy.swift, Info.plist, ); target = 5A1091C62EF17EDC0055EA7C /* TablePro */; diff --git a/TablePro/CLI/BridgeMain.swift b/TablePro/CLI/BridgeMain.swift new file mode 100644 index 000000000..7e727addd --- /dev/null +++ b/TablePro/CLI/BridgeMain.swift @@ -0,0 +1,58 @@ +import Foundation + +@main +struct TableProMcpBridge { + static func main() async { + let logger: any MCPBridgeLogger = MCPCompositeBridgeLogger([ + MCPOSBridgeLogger(category: "MCP.Bridge"), + MCPStderrBridgeLogger() + ]) + + let acquirer = MCPHandshakeAcquirer(logger: logger) + let handshake: MCPBridgeHandshake + do { + handshake = try await acquirer.acquire() + } catch { + logger.log(.error, "Handshake failed: \(error.localizedDescription)") + emitFatalJsonRpcError(message: "TablePro is not running. Launch the app and enable the MCP server.") + exit(1) + } + + guard let endpoint = handshake.endpoint() else { + logger.log(.error, "Handshake produced invalid endpoint") + emitFatalJsonRpcError(message: "Invalid MCP server endpoint") + exit(1) + } + + let upstream = MCPStreamableHttpClientTransport( + configuration: MCPStreamableHttpClientConfiguration( + endpoint: endpoint, + bearerToken: handshake.token, + tlsCertFingerprint: handshake.tlsCertFingerprint, + requestTimeout: .seconds(60), + serverInitiatedStream: false + ), + errorLogger: logger + ) + + let host = MCPStdioMessageTransport(errorLogger: logger) + + let proxy = BridgeProxy(host: host, upstream: upstream, logger: logger) + await proxy.run() + } + + private static func emitFatalJsonRpcError(message: String) { + let envelope = JsonRpcMessage.errorResponse( + JsonRpcErrorResponse( + id: nil, + error: JsonRpcError( + code: JsonRpcErrorCode.serverError, + message: message, + data: nil + ) + ) + ) + guard let data = try? JsonRpcCodec.encodeLine(envelope) else { return } + FileHandle.standardOutput.write(data) + } +} diff --git a/TablePro/CLI/BridgeProxy.swift b/TablePro/CLI/BridgeProxy.swift new file mode 100644 index 000000000..1537aed30 --- /dev/null +++ b/TablePro/CLI/BridgeProxy.swift @@ -0,0 +1,43 @@ +import Foundation + +actor BridgeProxy { + private let host: any MCPMessageTransport + private let upstream: any MCPMessageTransport + private let logger: any MCPBridgeLogger + + init(host: any MCPMessageTransport, upstream: any MCPMessageTransport, logger: any MCPBridgeLogger) { + self.host = host + self.upstream = upstream + self.logger = logger + } + + func run() async { + await withTaskGroup(of: Void.self) { [host, upstream, logger] group in + group.addTask { await Self.forward(from: host, to: upstream, direction: "host→upstream", logger: logger) } + group.addTask { await Self.forward(from: upstream, to: host, direction: "upstream→host", logger: logger) } + await group.waitForAll() + } + } + + private static func forward( + from source: any MCPMessageTransport, + to destination: any MCPMessageTransport, + direction: String, + logger: any MCPBridgeLogger + ) async { + do { + for try await message in source.inbound { + do { + try await destination.send(message) + } catch { + logger.log(.warning, "[\(direction)] send failed: \(error.localizedDescription)") + } + } + logger.log(.info, "[\(direction)] inbound stream closed") + } catch { + logger.log(.error, "[\(direction)] inbound failed: \(error.localizedDescription)") + } + + await destination.close() + } +} diff --git a/TablePro/CLI/Handshake.swift b/TablePro/CLI/Handshake.swift new file mode 100644 index 000000000..acefbb292 --- /dev/null +++ b/TablePro/CLI/Handshake.swift @@ -0,0 +1,105 @@ +import CryptoKit +import Foundation +import Security + +struct MCPBridgeHandshake: Codable, Sendable { + let port: Int + let token: String + let pid: Int32 + let protocolVersion: String + let tls: Bool? + let tlsCertFingerprint: String? +} + +enum MCPHandshakeError: Error, LocalizedError { + case launchFailed(status: Int32) + case timeout + case fileNotFound + + var errorDescription: String? { + switch self { + case .launchFailed(let status): + return "Failed to launch TablePro (open exit \(status))" + case .timeout: + return "Timed out waiting for TablePro MCP server to start" + case .fileNotFound: + return "Handshake file not found" + } + } +} + +struct MCPHandshakeAcquirer: Sendable { + private static let pollInterval: Duration = .milliseconds(200) + private static let pollTimeout: Duration = .seconds(10) + private static let launchUrl = "tablepro://integrations/start-mcp" + + let handshakePath: String + let logger: any MCPBridgeLogger + + init(logger: any MCPBridgeLogger) { + let home = FileManager.default.homeDirectoryForCurrentUser.path + self.handshakePath = "\(home)/Library/Application Support/TablePro/mcp-handshake.json" + self.logger = logger + } + + func acquire() async throws -> MCPBridgeHandshake { + if let existing = try? load(), isProcessRunning(pid: existing.pid) { + return existing + } + + if (try? load()) != nil { + logger.log(.warning, "Stale handshake detected; relaunching TablePro") + removeHandshake() + } + + try launchHostApp() + return try await pollForHandshake() + } + + private func load() throws -> MCPBridgeHandshake { + let url = URL(fileURLWithPath: handshakePath) + guard FileManager.default.fileExists(atPath: handshakePath) else { + throw MCPHandshakeError.fileNotFound + } + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(MCPBridgeHandshake.self, from: data) + } + + private func removeHandshake() { + try? FileManager.default.removeItem(atPath: handshakePath) + } + + private func isProcessRunning(pid: Int32) -> Bool { + kill(pid, 0) == 0 + } + + private func launchHostApp() throws { + logger.log(.info, "TablePro not running; launching via \(Self.launchUrl)") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-g", Self.launchUrl] + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + throw MCPHandshakeError.launchFailed(status: process.terminationStatus) + } + } + + private func pollForHandshake() async throws -> MCPBridgeHandshake { + let deadline = ContinuousClock().now.advanced(by: Self.pollTimeout) + while ContinuousClock().now < deadline { + if let handshake = try? load(), isProcessRunning(pid: handshake.pid) { + return handshake + } + try? await Task.sleep(for: Self.pollInterval) + } + throw MCPHandshakeError.timeout + } +} + +extension MCPBridgeHandshake { + func endpoint() -> URL? { + let scheme = (tls ?? false) ? "https" : "http" + return URL(string: "\(scheme)://127.0.0.1:\(port)/mcp") + } +} diff --git a/TablePro/CLI/MCPBridgeProxy.swift b/TablePro/CLI/MCPBridgeProxy.swift deleted file mode 100644 index f52bae633..000000000 --- a/TablePro/CLI/MCPBridgeProxy.swift +++ /dev/null @@ -1,328 +0,0 @@ -import CryptoKit -import Foundation -import Security - -struct MCPHandshake: Codable { - let port: Int - let token: String - let pid: Int32 - let protocolVersion: String - let tls: Bool? - let tlsCertFingerprint: String? -} - -private final class CertificatePinningDelegate: NSObject, URLSessionDelegate { - private let expectedFingerprint: String - - init(expectedFingerprint: String) { - self.expectedFingerprint = expectedFingerprint - } - - func urlSession( - _ session: URLSession, - didReceive challenge: URLAuthenticationChallenge - ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, - let trust = challenge.protectionSpace.serverTrust else { - return (.performDefaultHandling, nil) - } - - guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], - let serverCert = chain.first else { - return (.cancelAuthenticationChallenge, nil) - } - - let serverFingerprint = sha256Fingerprint(of: serverCert) - guard serverFingerprint == expectedFingerprint else { - return (.cancelAuthenticationChallenge, nil) - } - - return (.useCredential, URLCredential(trust: trust)) - } - - private func sha256Fingerprint(of certificate: SecCertificate) -> String { - let data = SecCertificateCopyData(certificate) as Data - return SHA256.hash(data: data) - .map { String(format: "%02X", $0) } - .joined(separator: ":") - } -} - -final class MCPBridgeProxy { - private static let pollInterval: TimeInterval = 0.2 - private static let pollTimeout: TimeInterval = 10.0 - private static let launchURL = "tablepro://integrations/start-mcp" - - private let handshakePath: String - private var sessionId: String? - - init() { - let home = FileManager.default.homeDirectoryForCurrentUser.path - self.handshakePath = "\(home)/Library/Application Support/TablePro/mcp-handshake.json" - } - - func run() async { - let handshake: MCPHandshake - do { - handshake = try await acquireHandshake() - } catch { - writeStderr("Error: \(error.localizedDescription)\n") - writeJsonRpcError( - id: .null, - code: -32_000, - message: "TablePro is not running. Launch the app and enable the MCP server." - ) - exit(1) - } - - let urlSession = makeSession(handshake: handshake) - let scheme = (handshake.tls ?? false) ? "https" : "http" - let baseUrl = "\(scheme)://127.0.0.1:\(handshake.port)/mcp" - await readLoop(baseUrl: baseUrl, bearerToken: handshake.token, urlSession: urlSession) - } - - private func acquireHandshake() async throws -> MCPHandshake { - if let handshake = try? loadHandshake(), isProcessRunning(pid: handshake.pid) { - return handshake - } - - if (try? loadHandshake()) != nil { - writeStderr("Stale handshake detected; relaunching TablePro\n") - removeHandshake() - } - - try launchHostApp() - return try await pollForHandshake() - } - - private func loadHandshake() throws -> MCPHandshake { - let data = try Data(contentsOf: URL(fileURLWithPath: handshakePath)) - return try JSONDecoder().decode(MCPHandshake.self, from: data) - } - - private func removeHandshake() { - try? FileManager.default.removeItem(atPath: handshakePath) - } - - private func isProcessRunning(pid: Int32) -> Bool { - kill(pid, 0) == 0 - } - - private func launchHostApp() throws { - writeStderr("TablePro not running; launching via \(Self.launchURL)\n") - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/open") - process.arguments = ["-g", Self.launchURL] - try process.run() - process.waitUntilExit() - if process.terminationStatus != 0 { - throw BridgeError.launchFailed(status: process.terminationStatus) - } - } - - private func pollForHandshake() async throws -> MCPHandshake { - let deadline = Date().addingTimeInterval(Self.pollTimeout) - while Date() < deadline { - if let handshake = try? loadHandshake(), isProcessRunning(pid: handshake.pid) { - return handshake - } - try? await Task.sleep(nanoseconds: UInt64(Self.pollInterval * 1_000_000_000)) - } - throw BridgeError.handshakeTimeout - } - - private func makeSession(handshake: MCPHandshake) -> URLSession { - let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = 60 - config.timeoutIntervalForResource = 60 - - let delegate: URLSessionDelegate? - if handshake.tls ?? false, let fingerprint = handshake.tlsCertFingerprint { - delegate = CertificatePinningDelegate(expectedFingerprint: fingerprint) - } else { - delegate = nil - } - return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) - } - - private func readLoop(baseUrl: String, bearerToken: String, urlSession: URLSession) async { - let stdin = FileHandle.standardInput - var buffer = Data() - - while true { - let chunk = stdin.availableData - guard !chunk.isEmpty else { - break - } - - buffer.append(chunk) - - while let newlineIndex = buffer.firstIndex(of: 0x0A) { - let lineData = buffer[buffer.startIndex.. String? { - for (rawKey, rawValue) in response.allHeaderFields { - guard let keyString = rawKey as? String, - keyString.lowercased() == key.lowercased(), - let valueString = rawValue as? String else { continue } - return valueString - } - return nil - } - - private func captureSessionId(from response: HTTPURLResponse) { - guard let value = headerValue(response, forKey: "mcp-session-id") else { return } - if sessionId == nil { - writeStderr("Session established: \(value)\n") - } - sessionId = value - } - - private func extractRequestId(from data: Data) -> JsonRpcId { - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return .null - } - - guard let id = object["id"] else { - return .null - } - - if let intId = id as? Int { - return .int(intId) - } - if let stringId = id as? String { - return .string(stringId) - } - - return .null - } - - private func writeJsonRpcError(id: JsonRpcId, code: Int, message: String) { - var errorResponse: [String: Any] = [ - "jsonrpc": "2.0", - "error": [ - "code": code, - "message": message - ] as [String: Any] - ] - - switch id { - case .null: - errorResponse["id"] = NSNull() - case .int(let value): - errorResponse["id"] = value - case .string(let value): - errorResponse["id"] = value - } - - guard let data = try? JSONSerialization.data(withJSONObject: errorResponse) else { return } - writeStdout(data) - writeStdout(Data([0x0A])) - } - - private func writeStdout(_ data: Data) { - FileHandle.standardOutput.write(data) - } - - private func writeStderr(_ message: String) { - guard let data = message.data(using: .utf8) else { return } - FileHandle.standardError.write(data) - } -} - -private enum JsonRpcId { - case null - case int(Int) - case string(String) -} - -private enum BridgeError: LocalizedError { - case invalidUrl - case launchFailed(status: Int32) - case handshakeTimeout - - var errorDescription: String? { - switch self { - case .invalidUrl: - "Invalid MCP server URL" - case .launchFailed(let status): - "Failed to launch TablePro (open exit \(status))" - case .handshakeTimeout: - "Timed out waiting for TablePro MCP server to start" - } - } -} diff --git a/TablePro/CLI/main.swift b/TablePro/CLI/main.swift deleted file mode 100644 index cfc9ef571..000000000 --- a/TablePro/CLI/main.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -let proxy = MCPBridgeProxy() - -Task { - await proxy.run() -} - -RunLoop.main.run() From 2108fb4909ca0b40a4a31151c91ed7da3c8225a4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 15:29:36 +0700 Subject: [PATCH 05/54] refactor(mcp): phase 6 wire new server, delete legacy code MCPServerManager is now a thin composition root: it builds an MCPHttpServerTransport, an MCPProtocolDispatcher with all method handlers registered, an MCPSessionStore, MCPBearerTokenAuthenticator, and MCPRateLimiter, then wires the transport's exchanges stream into the dispatcher. Public API (start/stop/restart/lazyStart/tokenStore/state) preserved so AppDelegate, AppSettingsManager, MCPSection, and the launch intent router don't need changes beyond a SessionSnapshot type rename. Legacy code deleted: MCPServer, MCPRouter, MCPRouteHandler, MCPHTTPParser, MCPMessageTypes (JSONRPCRequest/Response/etc), MCPSession, MCPSessionPhase, MCPRateLimiter, MCPToolHandler (+Integrations), MCPResourceHandler, Routes/MCPProtocolHandler, Routes/IntegrationsExchange Handler, JsonValueLegacyBridge. About 7000 lines removed. MCPConnectionBridge migrated from legacy JSONValue to the new JsonValue type. All tools, resource handlers, and helpers updated accordingly. MCPError extracted into its own file (still used by the connection bridge, auth policy, and pairing service). Pre-existing test target compile errors fixed in passing: typos in TableRowsMutationTests, missing 'try' in EvictionTests/RowOperationsDispatch Tests/SortCacheInvalidationTests, obsolete sidebarLoadingState references removed, DeeplinkParser migration in ConnectionSharingTests, MCPTokenStore Tests updated for ConnectionAccess, plus deletion of obsolete legacy MCP test files (MCPRouterTests, MCPAuthGuardTests, etc.) that tested code we just deleted. CHANGELOG entry under Unreleased explains the user-facing impact. xcodebuild build clean for both TablePro and mcp-server schemes; swiftlint strict zero violations on Core/MCP/. --- CHANGELOG.md | 6 + .../PluginDatabaseDriver.swift | 21 + TablePro.xcodeproj/project.pbxproj | 32 +- TablePro/Core/Database/DatabaseDriver.swift | 10 + TablePro/Core/MCP/LegacyMCPRateLimiter.swift | 94 --- TablePro/Core/MCP/LegacyMCPSession.swift | 95 --- TablePro/Core/MCP/MCPConnectionBridge.swift | 81 +- TablePro/Core/MCP/MCPError.swift | 75 ++ TablePro/Core/MCP/MCPHTTPParser.swift | 253 ------ TablePro/Core/MCP/MCPMessageTypes.swift | 428 ---------- TablePro/Core/MCP/MCPRateLimiter.swift | 94 --- TablePro/Core/MCP/MCPResourceHandler.swift | 137 --- TablePro/Core/MCP/MCPRouteHandler.swift | 7 - TablePro/Core/MCP/MCPRouter.swift | 485 ----------- TablePro/Core/MCP/MCPServer.swift | 481 ----------- TablePro/Core/MCP/MCPServerManager.swift | 338 ++++++-- TablePro/Core/MCP/MCPSession.swift | 95 --- TablePro/Core/MCP/MCPSessionPhase.swift | 20 - .../MCP/MCPToolHandler+Integrations.swift | 334 -------- TablePro/Core/MCP/MCPToolHandler.swift | 796 ------------------ .../Handlers/ResourcesListHandler.swift | 3 +- .../Handlers/ResourcesReadHandler.swift | 9 +- .../ConfirmDestructiveOperationTool.swift | 2 +- .../Core/MCP/Protocol/Tools/ConnectTool.swift | 4 +- .../Protocol/Tools/DescribeTableTool.swift | 4 +- .../MCP/Protocol/Tools/ExecuteQueryTool.swift | 4 +- .../MCP/Protocol/Tools/ExportDataTool.swift | 22 +- .../Protocol/Tools/FocusQueryTabTool.swift | 2 +- .../Tools/GetConnectionStatusTool.swift | 4 +- .../MCP/Protocol/Tools/GetTableDdlTool.swift | 4 +- .../Tools/JsonValueLegacyBridge.swift | 51 -- .../Protocol/Tools/ListConnectionsTool.swift | 4 +- .../Protocol/Tools/ListDatabasesTool.swift | 4 +- .../Protocol/Tools/ListRecentTabsTool.swift | 4 +- .../MCP/Protocol/Tools/ListSchemasTool.swift | 4 +- .../MCP/Protocol/Tools/ListTablesTool.swift | 4 +- .../Tools/MCPTabSnapshotProvider.swift | 66 ++ .../Tools/SearchQueryHistoryTool.swift | 2 +- .../Protocol/Tools/SwitchDatabaseTool.swift | 4 +- .../MCP/Protocol/Tools/SwitchSchemaTool.swift | 4 +- .../Protocol/Tools/ToolQueryExecutor.swift | 2 +- .../Routes/IntegrationsExchangeHandler.swift | 94 --- .../Core/MCP/Routes/MCPProtocolHandler.swift | 533 ------------ TablePro/Core/MCP/Session/MCPSession.swift | 4 +- .../Core/MCP/Session/MCPSessionStore.swift | 4 + .../Views/Settings/Sections/MCPSection.swift | 2 +- .../Core/MCP/LegacyMCPRateLimiterTests.swift | 216 ----- .../Core/MCP/MCPAuthGuardTests.swift | 128 --- .../Core/MCP/MCPRateLimiterTests.swift | 216 ----- TableProTests/Core/MCP/MCPRouterTests.swift | 167 ---- .../Core/MCP/MCPTokenStoreTests.swift | 26 +- .../Core/MCP/MCPToolHandlerExportTests.swift | 183 ---- .../MCP/MCPToolHandlerIntegrationTests.swift | 701 --------------- .../MCP/MCPToolHandlerSecurityTests.swift | 87 -- .../Services/ConnectionSharingTests.swift | 16 +- .../Core/Services/DeeplinkHandlerTests.swift | 664 --------------- .../TabPersistenceCoordinatorTests.swift | 24 - .../TableQueryBuilderMSSQLTests.swift | 3 +- .../WelcomeWindowSuppressionTests.swift | 286 ------- .../ViewModels/SidebarViewModelTests.swift | 1 - .../Main/CoordinatorReloadSidebarTests.swift | 63 -- TableProTests/Views/Main/EvictionTests.swift | 19 +- .../Main/RowOperationsDispatchTests.swift | 10 +- .../Main/SortCacheInvalidationTests.swift | 18 +- TableProTests/Views/SwitchDatabaseTests.swift | 115 --- 65 files changed, 571 insertions(+), 7098 deletions(-) delete mode 100644 TablePro/Core/MCP/LegacyMCPRateLimiter.swift delete mode 100644 TablePro/Core/MCP/LegacyMCPSession.swift create mode 100644 TablePro/Core/MCP/MCPError.swift delete mode 100644 TablePro/Core/MCP/MCPHTTPParser.swift delete mode 100644 TablePro/Core/MCP/MCPMessageTypes.swift delete mode 100644 TablePro/Core/MCP/MCPRateLimiter.swift delete mode 100644 TablePro/Core/MCP/MCPResourceHandler.swift delete mode 100644 TablePro/Core/MCP/MCPRouteHandler.swift delete mode 100644 TablePro/Core/MCP/MCPRouter.swift delete mode 100644 TablePro/Core/MCP/MCPServer.swift delete mode 100644 TablePro/Core/MCP/MCPSession.swift delete mode 100644 TablePro/Core/MCP/MCPSessionPhase.swift delete mode 100644 TablePro/Core/MCP/MCPToolHandler+Integrations.swift delete mode 100644 TablePro/Core/MCP/MCPToolHandler.swift delete mode 100644 TablePro/Core/MCP/Protocol/Tools/JsonValueLegacyBridge.swift create mode 100644 TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift delete mode 100644 TablePro/Core/MCP/Routes/IntegrationsExchangeHandler.swift delete mode 100644 TablePro/Core/MCP/Routes/MCPProtocolHandler.swift delete mode 100644 TableProTests/Core/MCP/LegacyMCPRateLimiterTests.swift delete mode 100644 TableProTests/Core/MCP/MCPAuthGuardTests.swift delete mode 100644 TableProTests/Core/MCP/MCPRateLimiterTests.swift delete mode 100644 TableProTests/Core/MCP/MCPRouterTests.swift delete mode 100644 TableProTests/Core/MCP/MCPToolHandlerExportTests.swift delete mode 100644 TableProTests/Core/MCP/MCPToolHandlerIntegrationTests.swift delete mode 100644 TableProTests/Core/MCP/MCPToolHandlerSecurityTests.swift delete mode 100644 TableProTests/Core/Services/DeeplinkHandlerTests.swift delete mode 100644 TableProTests/Core/Services/WelcomeWindowSuppressionTests.swift delete mode 100644 TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac0290a9..a74172392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- MCP: complete rewrite of the server, bridge, and protocol layer for spec compliance and reliability. Non-2xx responses now emit JSON-RPC error envelopes (the bridge previously forwarded plain `{"error":"..."}` bodies, which broke Claude Desktop's stdio parser when sessions expired). The stdio bridge no longer polls `availableData` for stdin (which silently exited mid-session) and uses incremental SSE parsing instead of buffering full responses. Idle session timeout raised from 5 to 15 minutes. Rate limiter now keys on `(client_address, principal_fingerprint)` instead of IP only, fixing a localhost auth-DoS surface. Streaming progress notifications via `notifications/progress` are now supported for long-running tool calls. + +### Fixed +- MCP: stale `Mcp-Session-Id` after idle timeout now produces a JSON-RPC `-32001 "Session not found"` envelope with HTTP 404, matching the spec and letting clients re-initialize cleanly. Previously the bridge forwarded a plain `{"error":"Session not found"}` body that Claude Desktop's parser rejected, hanging the request until a 4-minute client-side timeout fired. + ## [0.37.0] - 2026-05-01 ### Added diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index d5f56e1d0..4fd8d3a3c 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -279,6 +279,27 @@ public extension PluginDatabaseDriver { return "\"\(escaped)\"" } + func streamRows(query: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let result = try await self.execute(query: query) + let header = PluginStreamHeader( + columns: result.columns, + columnTypeNames: result.columnTypeNames + ) + continuation.yield(.header(header)) + if !result.rows.isEmpty { + continuation.yield(.rows(result.rows)) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + func escapeStringLiteral(_ value: String) -> String { var result = value result = result.replacingOccurrences(of: "'", with: "''") diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index b75c76c36..cc362ba00 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -318,27 +318,26 @@ 5A32BC082F9D5FC900BAEB5F /* Exceptions for "TablePro" folder in "mcp-server" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - CLI/BridgeMain.swift, - CLI/Handshake.swift, CLI/BridgeProxy.swift, - Core/MCP/Wire/JsonValue.swift, - Core/MCP/Wire/JsonRpcId.swift, - Core/MCP/Wire/JsonRpcVersion.swift, - Core/MCP/Wire/JsonRpcErrorCode.swift, - Core/MCP/Wire/JsonRpcError.swift, - Core/MCP/Wire/JsonRpcMessage.swift, - Core/MCP/Wire/JsonRpcCodec.swift, - Core/MCP/Wire/SseFrame.swift, - Core/MCP/Wire/SseEncoder.swift, - Core/MCP/Wire/SseDecoder.swift, - Core/MCP/Wire/HttpRequestHead.swift, - Core/MCP/Wire/HttpResponseHead.swift, + CLI/Handshake.swift, + Core/MCP/Transport/MCPBridgeLogger.swift, Core/MCP/Transport/MCPMessageTransport.swift, Core/MCP/Transport/MCPProtocolError.swift, Core/MCP/Transport/MCPStdioMessageTransport.swift, Core/MCP/Transport/MCPStreamableHttpClientTransport.swift, - Core/MCP/Transport/MCPBridgeLogger.swift, + Core/MCP/Wire/HttpRequestHead.swift, + Core/MCP/Wire/HttpResponseHead.swift, + Core/MCP/Wire/JsonRpcCodec.swift, + Core/MCP/Wire/JsonRpcError.swift, + Core/MCP/Wire/JsonRpcErrorCode.swift, + Core/MCP/Wire/JsonRpcId.swift, + Core/MCP/Wire/JsonRpcMessage.swift, + Core/MCP/Wire/JsonRpcVersion.swift, + Core/MCP/Wire/JsonValue.swift, + Core/MCP/Wire/SseDecoder.swift, + Core/MCP/Wire/SseEncoder.swift, + Core/MCP/Wire/SseFrame.swift, ); target = 5A32BBFF2F9D5F1300BAEB5F /* mcp-server */; }; @@ -478,10 +477,9 @@ 5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - CLI/BridgeMain.swift, - CLI/Handshake.swift, CLI/BridgeProxy.swift, + CLI/Handshake.swift, Info.plist, ); target = 5A1091C62EF17EDC0055EA7C /* TablePro */; diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 09880e787..9708f8eee 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -237,6 +237,16 @@ extension DatabaseDriver { userInfo: [NSLocalizedDescriptionKey: "Drop database is not supported by this driver"]) } + func createDatabaseFormSpec() async throws -> CreateDatabaseFormSpec? { nil } + + func createDatabase(_ request: CreateDatabaseRequest) async throws { + throw NSError( + domain: "DatabaseDriver", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Create database is not supported by this driver"] + ) + } + /// Default fetchAllDatabaseMetadata: falls back to per-database calls (N+1). /// Drivers should override with a single bulk query where possible. func fetchAllDatabaseMetadata() async throws -> [DatabaseMetadata] { diff --git a/TablePro/Core/MCP/LegacyMCPRateLimiter.swift b/TablePro/Core/MCP/LegacyMCPRateLimiter.swift deleted file mode 100644 index b7996d406..000000000 --- a/TablePro/Core/MCP/LegacyMCPRateLimiter.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import os - -actor LegacyMCPRateLimiter { - enum AuthRateResult: Sendable { - case allowed - case rateLimited(retryAfter: Duration) - } - - private struct FailureRecord { - var consecutiveFailures: Int - var lockedUntil: ContinuousClock.Instant? - var lastUpdated: ContinuousClock.Instant - } - - private static let logger = Logger(subsystem: "com.TablePro", category: "LegacyMCPRateLimiter") - - private static let staleEntryThreshold: Duration = .seconds(600) - private static let cleanupInterval: Duration = .seconds(300) - - private var records: [String: FailureRecord] = [:] - private var lastCleanup: ContinuousClock.Instant = .now - - func checkAndRecord(ip: String, success: Bool) -> AuthRateResult { - cleanupStaleEntriesIfNeeded() - - let now = ContinuousClock.now - - if let record = records[ip], let lockedUntil = record.lockedUntil, now < lockedUntil { - let remaining = lockedUntil - now - return .rateLimited(retryAfter: remaining) - } - - guard !success else { - records.removeValue(forKey: ip) - return .allowed - } - - var record = records[ip] ?? FailureRecord(consecutiveFailures: 0, lockedUntil: nil, lastUpdated: now) - record.consecutiveFailures += 1 - record.lastUpdated = now - - let lockoutDuration = lockoutDuration(forFailureCount: record.consecutiveFailures) - if let lockout = lockoutDuration { - record.lockedUntil = now + lockout - records[ip] = record - return .rateLimited(retryAfter: lockout) - } - - record.lockedUntil = nil - records[ip] = record - return .allowed - } - - func isLockedOut(ip: String) -> AuthRateResult { - let now = ContinuousClock.now - guard let record = records[ip], let lockedUntil = record.lockedUntil, now < lockedUntil else { - return .allowed - } - return .rateLimited(retryAfter: lockedUntil - now) - } - - private func lockoutDuration(forFailureCount count: Int) -> Duration? { - switch count { - case 1: - return nil - case 2: - return .seconds(1) - case 3: - return .seconds(5) - case 4: - return .seconds(30) - default: - return .seconds(300) - } - } - - private func cleanupStaleEntriesIfNeeded() { - let now = ContinuousClock.now - guard now - lastCleanup > Self.cleanupInterval else { return } - - lastCleanup = now - let threshold = now - Self.staleEntryThreshold - - let staleKeys = records.filter { $0.value.lastUpdated < threshold }.map(\.key) - for key in staleKeys { - records.removeValue(forKey: key) - } - - if !staleKeys.isEmpty { - Self.logger.info("Cleaned up \(staleKeys.count) stale rate limit entries") - } - } -} diff --git a/TablePro/Core/MCP/LegacyMCPSession.swift b/TablePro/Core/MCP/LegacyMCPSession.swift deleted file mode 100644 index 8e60c1ba8..000000000 --- a/TablePro/Core/MCP/LegacyMCPSession.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -import Network - -actor LegacyMCPSession { - let id: String - let createdAt: ContinuousClock.Instant - - var lastActivityAt: ContinuousClock.Instant - private(set) var phase: MCPSessionPhase = .created - var clientInfo: LegacyMCPClientInfo? - var sseConnection: NWConnection? - var runningTasks: [JSONRPCId: Task] = [:] - private(set) var eventCounter: Int = 0 - private(set) var remoteAddress: String? - - var authenticatedTokenId: UUID? { - if case .active(let tokenId, _) = phase { return tokenId } - return nil - } - - var tokenName: String? { - if case .active(_, let tokenName) = phase { return tokenName } - return nil - } - - init() { - self.id = UUID().uuidString - let now = ContinuousClock.now - self.createdAt = now - self.lastActivityAt = now - } - - func markActive() { - lastActivityAt = .now - } - - func cancelAllTasks() { - for (_, task) in runningTasks { - task.cancel() - } - runningTasks.removeAll() - } - - func transition(to next: MCPSessionPhase) throws { - guard isValidTransition(from: phase, to: next) else { - throw MCPError.invalidRequest( - "Invalid session phase transition from \(phase) to \(next)" - ) - } - phase = next - } - - private func isValidTransition(from current: MCPSessionPhase, to next: MCPSessionPhase) -> Bool { - switch (current, next) { - case (.created, .initializing), - (.created, .active), - (.created, .terminated), - (.initializing, .active), - (.initializing, .terminated), - (.active, .terminated): - return true - default: - return false - } - } - - func setClientInfo(_ info: LegacyMCPClientInfo?) { - clientInfo = info - } - - func setRemoteAddress(_ address: String?) { - remoteAddress = address - } - - func setSSEConnection(_ connection: NWConnection?) { - sseConnection = connection - } - - func cancelSSEConnection() { - sseConnection?.cancel() - } - - func addRunningTask(_ id: JSONRPCId, task: Task) { - runningTasks[id] = task - } - - func removeRunningTask(_ id: JSONRPCId) -> Task? { - runningTasks.removeValue(forKey: id) - } - - func nextEventId() -> String { - eventCounter += 1 - return String(eventCounter) - } -} diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index d56156b27..f42f05262 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -1,10 +1,3 @@ -// -// MCPConnectionBridge.swift -// TablePro -// -// Bridges MCP tool/resource handlers to DatabaseManager and driver APIs. -// - import Foundation import os @@ -13,7 +6,7 @@ public actor MCPConnectionBridge { public init() {} - func listConnections() async -> JSONValue { + func listConnections() async -> JsonValue { let (connections, activeSessions) = await MainActor.run { let conns = ConnectionStorage.shared.loadConnections() .filter { $0.externalAccess != .blocked } @@ -21,7 +14,7 @@ public actor MCPConnectionBridge { return (conns, sessions) } - let items: [JSONValue] = connections.map { conn in + let items: [JsonValue] = connections.map { conn in let session = activeSessions[conn.id] let isConnected = session?.status.isConnected ?? false let policy = conn.aiPolicy ?? AIConnectionPolicy.askEachTime @@ -43,21 +36,19 @@ public actor MCPConnectionBridge { return .object(["connections": .array(items)]) } - func connect(connectionId: UUID) async throws -> JSONValue { + func connect(connectionId: UUID) async throws -> JsonValue { let connection = try await resolveConnection(connectionId) - // Check if session already exists and is connected -- reuse without switching UI let existingSession = await MainActor.run { DatabaseManager.shared.activeSessions[connectionId] } if let existing = existingSession, existing.driver != nil { - // Already connected, return current state without switching the UI's active session let serverVersion = existing.driver?.serverVersion let currentDatabase = existing.activeDatabase let currentSchema = existing.currentSchema - var result: [String: JSONValue] = [ + var result: [String: JsonValue] = [ "status": "connected", "current_database": .string(currentDatabase) ] @@ -81,7 +72,7 @@ public actor MCPConnectionBridge { ) } - var result: [String: JSONValue] = [ + var result: [String: JsonValue] = [ "status": "connected", "current_database": .string(currentDatabase ?? "") ] @@ -105,7 +96,7 @@ public actor MCPConnectionBridge { await DatabaseManager.shared.disconnectSession(connectionId) } - func getConnectionStatus(connectionId: UUID) async throws -> JSONValue { + func getConnectionStatus(connectionId: UUID) async throws -> JsonValue { let core = await MainActor.run { () -> (status: ConnectionStatus, database: String, schema: String?)? in guard let session = DatabaseManager.shared.activeSessions[connectionId] else { @@ -129,7 +120,7 @@ public actor MCPConnectionBridge { } let statusString: String - var errorDetail: JSONValue? + var errorDetail: JsonValue? switch core.status { case .connected: statusString = "connected" case .connecting: statusString = "connecting" @@ -141,7 +132,7 @@ public actor MCPConnectionBridge { ]) } - var result: [String: JSONValue] = [ + var result: [String: JsonValue] = [ "status": .string(statusString), "current_database": .string(core.database), "connected_at": .string(ISO8601DateFormatter().string(from: meta.connectedAt)), @@ -165,7 +156,7 @@ public actor MCPConnectionBridge { query: String, maxRows: Int, timeoutSeconds: Int - ) async throws -> JSONValue { + ) async throws -> JsonValue { let (driver, databaseType) = try await resolveDriver(connectionId) let normalizedQuery = Self.stripTrailingSemicolons(query) let isWrite = QueryClassifier.isWriteQuery(normalizedQuery, databaseType: databaseType) @@ -204,8 +195,8 @@ public actor MCPConnectionBridge { let executionTimeMs = (CFAbsoluteTimeGetCurrent() - startTime) * 1_000 let isTruncated = result.isTruncated - let jsonColumns: [JSONValue] = result.columns.map { .string($0) } - let jsonRows: [JSONValue] = result.rows.map { row in + let jsonColumns: [JsonValue] = result.columns.map { .string($0) } + let jsonRows: [JsonValue] = result.rows.map { row in .array(row.map { cell in if let value = cell { return .string(value) @@ -214,7 +205,7 @@ public actor MCPConnectionBridge { }) } - var response: [String: JSONValue] = [ + var response: [String: JsonValue] = [ "columns": .array(jsonColumns), "rows": .array(jsonRows), "row_count": .int(result.rows.count), @@ -229,7 +220,7 @@ public actor MCPConnectionBridge { return .object(response) } - func listTables(connectionId: UUID, includeRowCounts: Bool) async throws -> JSONValue { + func listTables(connectionId: UUID, includeRowCounts: Bool) async throws -> JsonValue { let cachedTables = await MainActor.run { SchemaService.shared.tables(for: connectionId) } @@ -244,8 +235,8 @@ public actor MCPConnectionBridge { } } - let jsonTables: [JSONValue] = tables.map { table in - var obj: [String: JSONValue] = [ + let jsonTables: [JsonValue] = tables.map { table in + var obj: [String: JsonValue] = [ "name": .string(table.name), "type": .string(table.type.rawValue) ] @@ -258,11 +249,9 @@ public actor MCPConnectionBridge { return .object(["tables": .array(jsonTables)]) } - func describeTable(connectionId: UUID, table: String, schema: String?) async throws -> JSONValue { + func describeTable(connectionId: UUID, table: String, schema: String?) async throws -> JsonValue { let (driver, _) = try await resolveDriver(connectionId) - // Sequential fetches: driver connections are NOT thread-safe, - // so concurrent calls on the same driver would race. return try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { let columns = try await driver.fetchColumns(table: table, schema: schema) let indexes = try await driver.fetchIndexes(table: table) @@ -270,8 +259,8 @@ public actor MCPConnectionBridge { let approxRowCount = try await driver.fetchApproximateRowCount(table: table) let ddl = try? await driver.fetchTableDDL(table: table) - let jsonColumns: [JSONValue] = columns.map { col in - var obj: [String: JSONValue] = [ + let jsonColumns: [JsonValue] = columns.map { col in + var obj: [String: JsonValue] = [ "name": .string(col.name), "data_type": .string(col.dataType), "is_nullable": .bool(col.isNullable), @@ -283,7 +272,7 @@ public actor MCPConnectionBridge { return .object(obj) } - let jsonIndexes: [JSONValue] = indexes.map { idx in + let jsonIndexes: [JsonValue] = indexes.map { idx in .object([ "name": .string(idx.name), "columns": .array(idx.columns.map { .string($0) }), @@ -293,8 +282,8 @@ public actor MCPConnectionBridge { ]) } - let jsonFKs: [JSONValue] = foreignKeys.map { fk in - var obj: [String: JSONValue] = [ + let jsonFKs: [JsonValue] = foreignKeys.map { fk in + var obj: [String: JsonValue] = [ "name": .string(fk.name), "column": .string(fk.column), "referenced_table": .string(fk.referencedTable), @@ -308,7 +297,7 @@ public actor MCPConnectionBridge { return .object(obj) } - var result: [String: JSONValue] = [ + var result: [String: JsonValue] = [ "columns": .array(jsonColumns), "indexes": .array(jsonIndexes), "foreign_keys": .array(jsonFKs) @@ -324,7 +313,7 @@ public actor MCPConnectionBridge { } } - func listDatabases(connectionId: UUID) async throws -> JSONValue { + func listDatabases(connectionId: UUID) async throws -> JsonValue { let (driver, _) = try await resolveDriver(connectionId) let databases = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { try await driver.fetchDatabases() @@ -332,7 +321,7 @@ public actor MCPConnectionBridge { return .object(["databases": .array(databases.map { .string($0) })]) } - func listSchemas(connectionId: UUID) async throws -> JSONValue { + func listSchemas(connectionId: UUID) async throws -> JsonValue { let (driver, _) = try await resolveDriver(connectionId) let schemas = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { try await driver.fetchSchemas() @@ -340,7 +329,7 @@ public actor MCPConnectionBridge { return .object(["schemas": .array(schemas.map { .string($0) })]) } - func getTableDDL(connectionId: UUID, table: String, schema: String?) async throws -> JSONValue { + func getTableDDL(connectionId: UUID, table: String, schema: String?) async throws -> JsonValue { let (driver, _) = try await resolveDriver(connectionId) let ddl = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { try await driver.fetchTableDDL(table: table) @@ -348,8 +337,7 @@ public actor MCPConnectionBridge { return .object(["ddl": .string(ddl)]) } - func switchDatabase(connectionId: UUID, database: String) async throws -> JSONValue { - // switchDatabase is @MainActor; Swift hops automatically for async calls. + func switchDatabase(connectionId: UUID, database: String) async throws -> JsonValue { try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId) return .object([ "status": "switched", @@ -357,8 +345,7 @@ public actor MCPConnectionBridge { ]) } - func switchSchema(connectionId: UUID, schema: String) async throws -> JSONValue { - // switchSchema is @MainActor; Swift hops automatically for async calls. + func switchSchema(connectionId: UUID, schema: String) async throws -> JsonValue { try await DatabaseManager.shared.switchSchema(to: schema, for: connectionId) return .object([ "status": "switched", @@ -366,7 +353,7 @@ public actor MCPConnectionBridge { ]) } - func fetchSchemaResource(connectionId: UUID) async throws -> JSONValue { + func fetchSchemaResource(connectionId: UUID) async throws -> JsonValue { let cachedTables = await MainActor.run { SchemaService.shared.tables(for: connectionId) } @@ -384,13 +371,13 @@ public actor MCPConnectionBridge { let limitedTables = Array(tables.prefix(100)) - var tableSchemas: [JSONValue] = [] + 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 + let jsonCols: [JsonValue] = columns.map { col in .object([ "name": .string(col.name), "data_type": .string(col.dataType), @@ -406,7 +393,7 @@ public actor MCPConnectionBridge { ])) } - var result: [String: JSONValue] = ["tables": .array(tableSchemas)] + var result: [String: JsonValue] = ["tables": .array(tableSchemas)] if tables.count > 100 { result["truncated"] = .bool(true) result["total_tables"] = .int(tables.count) @@ -420,7 +407,7 @@ public actor MCPConnectionBridge { limit: Int, search: String?, dateFilter: String? - ) async throws -> JSONValue { + ) async throws -> JsonValue { let filter: DateFilter switch dateFilter { case "today": filter = .today @@ -436,8 +423,8 @@ public actor MCPConnectionBridge { dateFilter: filter ) - let jsonEntries: [JSONValue] = entries.map { entry in - var obj: [String: JSONValue] = [ + 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), diff --git a/TablePro/Core/MCP/MCPError.swift b/TablePro/Core/MCP/MCPError.swift new file mode 100644 index 000000000..842a40575 --- /dev/null +++ b/TablePro/Core/MCP/MCPError.swift @@ -0,0 +1,75 @@ +import Foundation + +enum MCPError: Error, Sendable { + case parseError + case invalidRequest(String) + case methodNotFound(String) + case invalidParams(String) + case internalError(String) + case notConnected(UUID) + case forbidden(String, context: [String: String]? = nil) + case timeout(String, context: [String: String]? = nil) + case resultTooLarge + case serverDisabled + case notFound(String) + case expired(String) + case userCancelled + + var code: Int { + switch self { + case .parseError: -32_700 + case .invalidRequest: -32_600 + case .methodNotFound: -32_601 + case .invalidParams: -32_602 + case .internalError: -32_603 + case .notConnected: -32_000 + case .forbidden: -32_001 + case .timeout: -32_002 + case .resultTooLarge: -32_003 + case .serverDisabled: -32_004 + case .notFound: -32_005 + case .expired: -32_006 + case .userCancelled: -32_007 + } + } + + var message: String { + switch self { + case .parseError: + "Parse error" + case .invalidRequest(let detail): + "Invalid request: \(detail)" + case .methodNotFound(let method): + "Method not found: \(method)" + case .invalidParams(let detail): + "Invalid params: \(detail)" + case .internalError(let detail): + "Internal error: \(detail)" + case .notConnected(let connectionId): + "Not connected: \(connectionId)" + case .forbidden(let detail, _): + "Forbidden: \(detail)" + case .timeout(let detail, _): + "Timeout: \(detail)" + case .resultTooLarge: + "Result too large" + case .serverDisabled: + "MCP server is disabled" + case .notFound(let detail): + "Not found: \(detail)" + case .expired(let detail): + "Expired: \(detail)" + case .userCancelled: + "User cancelled" + } + } + + var isUserCancelled: Bool { + if case .userCancelled = self { return true } + return false + } +} + +extension MCPError: LocalizedError { + var errorDescription: String? { message } +} diff --git a/TablePro/Core/MCP/MCPHTTPParser.swift b/TablePro/Core/MCP/MCPHTTPParser.swift deleted file mode 100644 index 5662aa73e..000000000 --- a/TablePro/Core/MCP/MCPHTTPParser.swift +++ /dev/null @@ -1,253 +0,0 @@ -import Foundation -import os - -struct HTTPRequest: Sendable { - enum Method: String, Sendable { - case get = "GET" - case post = "POST" - case delete = "DELETE" - case options = "OPTIONS" - } - - let method: Method - let path: String - let headers: [String: String] - let body: Data? - var remoteIP: String? - - init(method: Method, path: String, headers: [String: String], body: Data?, remoteIP: String? = nil) { - self.method = method - self.path = path - self.headers = headers - self.body = body - self.remoteIP = remoteIP - } - - func withRemoteIP(_ remoteIP: String?) -> HTTPRequest { - HTTPRequest(method: method, path: path, headers: headers, body: body, remoteIP: remoteIP) - } -} - -enum HTTPParseError: Error, Sendable { - case incomplete - case malformedRequestLine - case malformedHeaders - case unsupportedMethod(String) - case bodyTooLarge - case malformedChunkedEncoding -} - -enum MCPHTTPParser { - private static let logger = Logger(subsystem: "com.TablePro", category: "MCPHTTPParser") - - static let maxBodySize = 10 * 1_024 * 1_024 - - static func parse(_ data: Data) -> Result { - let crlfcrlf = Data([0x0D, 0x0A, 0x0D, 0x0A]) - let lflf = Data([0x0A, 0x0A]) - - let headerEndRange: Range - if let range = data.range(of: crlfcrlf) { - headerEndRange = range - } else if let range = data.range(of: lflf) { - headerEndRange = range - } else { - return .failure(.incomplete) - } - - let headerData = data[data.startIndex..= 2 else { - return .failure(.malformedRequestLine) - } - - let methodString = String(requestParts[0]) - guard let method = HTTPRequest.Method(rawValue: methodString) else { - return .failure(.unsupportedMethod(methodString)) - } - - let path = String(requestParts[1]) - - var headers: [String: String] = [:] - for i in 1.. maxBodySize { - return .failure(.bodyTooLarge) - } - body = decoded - case .failure(let error): - return .failure(error) - } - } else if let contentLengthStr = headers["content-length"], - let contentLength = Int(contentLengthStr) - { - if contentLength > maxBodySize { - return .failure(.bodyTooLarge) - } - - let availableBytes = data.count - bodyStartIndex - if availableBytes < contentLength { - return .failure(.incomplete) - } - - body = data[bodyStartIndex..<(bodyStartIndex + contentLength)] - } - } - - return .success(HTTPRequest( - method: method, - path: path, - headers: headers, - body: body - )) - } - - private static func decodeChunkedBody(_ data: Data) -> Result { - var result = Data() - var offset = data.startIndex - - while offset < data.endIndex { - guard let lineEnd = findCRLF(in: data, from: offset) else { - return .failure(.incomplete) - } - - let sizeData = data[offset.. maxBodySize { - return .failure(.bodyTooLarge) - } - - result.append(data[chunkDataStart.. Data.Index? { - var i = start - while i < data.endIndex - 1 { - if data[i] == 0x0D, data[i + 1] == 0x0A { - return i - } - i += 1 - } - return nil - } - - static func buildResponse( - status: Int, - statusText: String, - headers: [(String, String)], - body: Data? - ) -> Data { - var response = "HTTP/1.1 \(status) \(statusText)\r\n" - for (key, value) in headers { - response += "\(key): \(value)\r\n" - } - if let body { - response += "Content-Length: \(body.count)\r\n" - } - response += "\r\n" - var data = Data(response.utf8) - if let body { - data.append(body) - } - return data - } - - static func buildSSEHeaders(sessionId: String, corsHeaders: [(String, String)] = []) -> Data { - var response = "HTTP/1.1 200 OK\r\n" - + "Content-Type: text/event-stream\r\n" - + "Cache-Control: no-cache\r\n" - + "Connection: keep-alive\r\n" - + "Mcp-Session-Id: \(sessionId)\r\n" - for (key, value) in corsHeaders { - response += "\(key): \(value)\r\n" - } - response += "\r\n" - return Data(response.utf8) - } - - static func buildSSEEvent(data: Data, id: String? = nil) -> Data { - var event = Data() - if let id { - event.append(Data("id: \(id)\n".utf8)) - } - event.append(Data("data: ".utf8)) - event.append(data) - event.append(Data("\n\n".utf8)) - return event - } - - static func statusText(for code: Int) -> String { - switch code { - case 200: return "OK" - case 202: return "Accepted" - case 204: return "No Content" - case 400: return "Bad Request" - case 401: return "Unauthorized" - case 403: return "Forbidden" - case 404: return "Not Found" - case 405: return "Method Not Allowed" - case 406: return "Not Acceptable" - case 413: return "Content Too Large" - case 429: return "Too Many Requests" - case 500: return "Internal Server Error" - default: return "Unknown" - } - } -} diff --git a/TablePro/Core/MCP/MCPMessageTypes.swift b/TablePro/Core/MCP/MCPMessageTypes.swift deleted file mode 100644 index f314d190c..000000000 --- a/TablePro/Core/MCP/MCPMessageTypes.swift +++ /dev/null @@ -1,428 +0,0 @@ -// -// MCPMessageTypes.swift -// TablePro -// - -import Foundation - -enum JSONValue: Codable, Equatable, Sendable { - case null - case bool(Bool) - case int(Int) - case double(Double) - case string(String) - case array([JSONValue]) - case object([String: JSONValue]) - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if container.decodeNil() { - self = .null - return - } - - if let boolValue = try? container.decode(Bool.self) { - self = .bool(boolValue) - return - } - - if let intValue = try? container.decode(Int.self) { - self = .int(intValue) - return - } - - if let doubleValue = try? container.decode(Double.self) { - self = .double(doubleValue) - return - } - - if let stringValue = try? container.decode(String.self) { - self = .string(stringValue) - return - } - - if let arrayValue = try? container.decode([JSONValue].self) { - self = .array(arrayValue) - return - } - - if let objectValue = try? container.decode([String: JSONValue].self) { - self = .object(objectValue) - return - } - - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode JSONValue") - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .null: - try container.encodeNil() - case .bool(let value): - try container.encode(value) - case .int(let value): - try container.encode(value) - case .double(let value): - try container.encode(value) - case .string(let value): - try container.encode(value) - case .array(let value): - try container.encode(value) - case .object(let value): - try container.encode(value) - } - } -} - -extension JSONValue: ExpressibleByStringLiteral { - init(stringLiteral value: String) { - self = .string(value) - } -} - -extension JSONValue: ExpressibleByIntegerLiteral { - init(integerLiteral value: Int) { - self = .int(value) - } -} - -extension JSONValue: ExpressibleByBooleanLiteral { - init(booleanLiteral value: Bool) { - self = .bool(value) - } -} - -extension JSONValue: ExpressibleByNilLiteral { - init(nilLiteral: ()) { - self = .null - } -} - -extension JSONValue: ExpressibleByArrayLiteral { - init(arrayLiteral elements: JSONValue...) { - self = .array(elements) - } -} - -extension JSONValue: ExpressibleByDictionaryLiteral { - init(dictionaryLiteral elements: (String, JSONValue)...) { - self = .object(Dictionary(uniqueKeysWithValues: elements)) - } -} - -extension JSONValue { - subscript(key: String) -> JSONValue? { - guard case .object(let dict) = self else { return nil } - return dict[key] - } - - var stringValue: String? { - guard case .string(let value) = self else { return nil } - return value - } - - var intValue: Int? { - guard case .int(let value) = self else { return nil } - return value - } - - var boolValue: Bool? { - guard case .bool(let value) = self else { return nil } - return value - } - - var doubleValue: Double? { - switch self { - case .double(let value): - return value - case .int(let value): - return Double(value) - default: - return nil - } - } - - var arrayValue: [JSONValue]? { - guard case .array(let value) = self else { return nil } - return value - } - - var objectValue: [String: JSONValue]? { - guard case .object(let value) = self else { return nil } - return value - } -} - -enum JSONRPCId: Codable, Equatable, Hashable, Sendable { - case string(String) - case int(Int) - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if let intValue = try? container.decode(Int.self) { - self = .int(intValue) - return - } - - if let stringValue = try? container.decode(String.self) { - self = .string(stringValue) - return - } - - throw DecodingError.dataCorruptedError(in: container, debugDescription: "JSONRPCId must be a string or integer") - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .string(let value): - try container.encode(value) - case .int(let value): - try container.encode(value) - } - } -} - -struct JSONRPCRequest: Codable, Sendable { - let jsonrpc: String - let id: JSONRPCId? - let method: String - let params: JSONValue? -} - -struct JSONRPCResponse: Codable, Sendable { - let id: JSONRPCId - let result: JSONValue - - var jsonrpc: String { "2.0" } - - enum CodingKeys: String, CodingKey { - case jsonrpc - case id - case result - } - - init(id: JSONRPCId, result: JSONValue) { - self.id = id - self.result = result - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - _ = try container.decode(String.self, forKey: .jsonrpc) - id = try container.decode(JSONRPCId.self, forKey: .id) - result = try container.decode(JSONValue.self, forKey: .result) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode("2.0", forKey: .jsonrpc) - try container.encode(id, forKey: .id) - try container.encode(result, forKey: .result) - } -} - -struct JSONRPCErrorResponse: Codable, Sendable { - let id: JSONRPCId? - let error: JSONRPCErrorDetail - - var jsonrpc: String { "2.0" } - - enum CodingKeys: String, CodingKey { - case jsonrpc - case id - case error - } - - init(id: JSONRPCId?, error: JSONRPCErrorDetail) { - self.id = id - self.error = error - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - _ = try container.decode(String.self, forKey: .jsonrpc) - id = try container.decodeIfPresent(JSONRPCId.self, forKey: .id) - error = try container.decode(JSONRPCErrorDetail.self, forKey: .error) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode("2.0", forKey: .jsonrpc) - try container.encode(id, forKey: .id) - try container.encode(error, forKey: .error) - } -} - -struct JSONRPCErrorDetail: Codable, Sendable { - let code: Int - let message: String - let data: JSONValue? -} - -enum MCPError: Error, Sendable { - case parseError - case invalidRequest(String) - case methodNotFound(String) - case invalidParams(String) - case internalError(String) - case notConnected(UUID) - case forbidden(String, context: [String: String]? = nil) - case timeout(String, context: [String: String]? = nil) - case resultTooLarge - case serverDisabled - case notFound(String) - case expired(String) - case userCancelled - - var code: Int { - switch self { - case .parseError: -32_700 - case .invalidRequest: -32_600 - case .methodNotFound: -32_601 - case .invalidParams: -32_602 - case .internalError: -32_603 - case .notConnected: -32_000 - case .forbidden: -32_001 - case .timeout: -32_002 - case .resultTooLarge: -32_003 - case .serverDisabled: -32_004 - case .notFound: -32_005 - case .expired: -32_006 - case .userCancelled: -32_007 - } - } - - var message: String { - switch self { - case .parseError: - "Parse error" - case .invalidRequest(let detail): - "Invalid request: \(detail)" - case .methodNotFound(let method): - "Method not found: \(method)" - case .invalidParams(let detail): - "Invalid params: \(detail)" - case .internalError(let detail): - "Internal error: \(detail)" - case .notConnected(let connectionId): - "Not connected: \(connectionId)" - case .forbidden(let detail, _): - "Forbidden: \(detail)" - case .timeout(let detail, _): - "Timeout: \(detail)" - case .resultTooLarge: - "Result too large" - case .serverDisabled: - "MCP server is disabled" - case .notFound(let detail): - "Not found: \(detail)" - case .expired(let detail): - "Expired: \(detail)" - case .userCancelled: - "User cancelled" - } - } - - private var contextData: JSONValue? { - switch self { - case .forbidden(_, let context), .timeout(_, let context): - guard let context, !context.isEmpty else { return nil } - var dict: [String: JSONValue] = [:] - for (key, value) in context { - dict[key] = .string(value) - } - return .object(dict) - case .notConnected(let connectionId): - return .object(["connection_id": .string(connectionId.uuidString)]) - default: - return nil - } - } - - func toJsonRpcError(id: JSONRPCId?) -> JSONRPCErrorResponse { - JSONRPCErrorResponse( - id: id, - error: JSONRPCErrorDetail(code: code, message: message, data: contextData) - ) - } - - var isUserCancelled: Bool { - if case .userCancelled = self { return true } - return false - } -} - -extension MCPError: LocalizedError { - var errorDescription: String? { message } -} - -struct LegacyMCPClientInfo: Codable, Sendable { - let name: String - let version: String? -} - -struct MCPInitializeResult: Codable, Sendable { - let protocolVersion: String - let capabilities: MCPServerCapabilities - let serverInfo: MCPServerInfo -} - -struct MCPServerCapabilities: Codable, Sendable { - let tools: ToolCapability? - let resources: ResourceCapability? - - struct ToolCapability: Codable, Sendable { - let listChanged: Bool - } - - struct ResourceCapability: Codable, Sendable { - let subscribe: Bool - let listChanged: Bool - } -} - -struct MCPServerInfo: Codable, Sendable { - let name: String - let version: String -} - -struct MCPToolDefinition: Codable, Sendable { - let name: String - let description: String - let inputSchema: JSONValue -} - -struct MCPToolResult: Codable, Sendable { - let content: [MCPContent] - let isError: Bool? -} - -struct MCPContent: Codable, Sendable { - let type: String - let text: String - - static func text(_ value: String) -> MCPContent { - MCPContent(type: "text", text: value) - } -} - -struct MCPResourceDefinition: Codable, Sendable { - let uri: String - let name: String - let description: String? - let mimeType: String? -} - -struct MCPResourceContent: Codable, Sendable { - let uri: String - let mimeType: String? - let text: String? -} - -struct MCPResourceReadResult: Codable, Sendable { - let contents: [MCPResourceContent] -} diff --git a/TablePro/Core/MCP/MCPRateLimiter.swift b/TablePro/Core/MCP/MCPRateLimiter.swift deleted file mode 100644 index edf5ac0d6..000000000 --- a/TablePro/Core/MCP/MCPRateLimiter.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import os - -actor MCPRateLimiter { - enum AuthRateResult: Sendable { - case allowed - case rateLimited(retryAfter: Duration) - } - - private struct FailureRecord { - var consecutiveFailures: Int - var lockedUntil: ContinuousClock.Instant? - var lastUpdated: ContinuousClock.Instant - } - - private static let logger = Logger(subsystem: "com.TablePro", category: "MCPRateLimiter") - - private static let staleEntryThreshold: Duration = .seconds(600) - private static let cleanupInterval: Duration = .seconds(300) - - private var records: [String: FailureRecord] = [:] - private var lastCleanup: ContinuousClock.Instant = .now - - func checkAndRecord(ip: String, success: Bool) -> AuthRateResult { - cleanupStaleEntriesIfNeeded() - - let now = ContinuousClock.now - - if let record = records[ip], let lockedUntil = record.lockedUntil, now < lockedUntil { - let remaining = lockedUntil - now - return .rateLimited(retryAfter: remaining) - } - - guard !success else { - records.removeValue(forKey: ip) - return .allowed - } - - var record = records[ip] ?? FailureRecord(consecutiveFailures: 0, lockedUntil: nil, lastUpdated: now) - record.consecutiveFailures += 1 - record.lastUpdated = now - - let lockoutDuration = lockoutDuration(forFailureCount: record.consecutiveFailures) - if let lockout = lockoutDuration { - record.lockedUntil = now + lockout - records[ip] = record - return .rateLimited(retryAfter: lockout) - } - - record.lockedUntil = nil - records[ip] = record - return .allowed - } - - func isLockedOut(ip: String) -> AuthRateResult { - let now = ContinuousClock.now - guard let record = records[ip], let lockedUntil = record.lockedUntil, now < lockedUntil else { - return .allowed - } - return .rateLimited(retryAfter: lockedUntil - now) - } - - private func lockoutDuration(forFailureCount count: Int) -> Duration? { - switch count { - case 1: - return nil - case 2: - return .seconds(1) - case 3: - return .seconds(5) - case 4: - return .seconds(30) - default: - return .seconds(300) - } - } - - private func cleanupStaleEntriesIfNeeded() { - let now = ContinuousClock.now - guard now - lastCleanup > Self.cleanupInterval else { return } - - lastCleanup = now - let threshold = now - Self.staleEntryThreshold - - let staleKeys = records.filter { $0.value.lastUpdated < threshold }.map(\.key) - for key in staleKeys { - records.removeValue(forKey: key) - } - - if !staleKeys.isEmpty { - Self.logger.info("Cleaned up \(staleKeys.count) stale rate limit entries") - } - } -} diff --git a/TablePro/Core/MCP/MCPResourceHandler.swift b/TablePro/Core/MCP/MCPResourceHandler.swift deleted file mode 100644 index aa4b36226..000000000 --- a/TablePro/Core/MCP/MCPResourceHandler.swift +++ /dev/null @@ -1,137 +0,0 @@ -import Foundation -import os - -final class MCPResourceHandler: Sendable { - private static let logger = Logger(subsystem: "com.TablePro", category: "MCPResourceHandler") - - private let bridge: MCPConnectionBridge - private let authPolicy: MCPAuthPolicy - - init(bridge: MCPConnectionBridge, authPolicy: MCPAuthPolicy) { - self.bridge = bridge - self.authPolicy = authPolicy - } - - func handleResourceRead(uri: String, sessionId: String) async throws -> MCPResourceReadResult { - guard let components = URLComponents(string: uri) else { - throw MCPError.invalidParams("Malformed URI: \(uri)") - } - - guard components.scheme == "tablepro" else { - throw MCPError.invalidParams("Unsupported URI scheme: \(components.scheme ?? "nil")") - } - - let pathSegments = parsePathSegments(from: uri) - - if pathSegments == ["connections"] { - return try await handleConnectionsList(uri: uri) - } - - if pathSegments.count == 3, - pathSegments[0] == "connections", - pathSegments[2] == "schema" - { - guard let connectionId = UUID(uuidString: pathSegments[1]) else { - throw MCPError.invalidParams("Invalid connection UUID in URI") - } - return try await handleSchemaResource(uri: uri, connectionId: connectionId, sessionId: sessionId) - } - - if pathSegments.count == 3, - pathSegments[0] == "connections", - pathSegments[2] == "history" - { - guard let connectionId = UUID(uuidString: pathSegments[1]) else { - throw MCPError.invalidParams("Invalid connection UUID in URI") - } - let queryItems = components.queryItems ?? [] - return try await handleHistoryResource( - uri: uri, - connectionId: connectionId, - queryItems: queryItems, - sessionId: sessionId - ) - } - - throw MCPError.invalidParams("Unknown resource URI: \(uri)") - } - - private func handleConnectionsList(uri: String) async throws -> MCPResourceReadResult { - let result = await bridge.listConnections() - let jsonString = encodeJSON(result) - return MCPResourceReadResult(contents: [ - MCPResourceContent(uri: uri, mimeType: "application/json", text: jsonString) - ]) - } - - private func handleSchemaResource(uri: String, connectionId: UUID, sessionId: String) async throws -> MCPResourceReadResult { - try await authPolicy.resolveAndAuthorize( - token: MCPToolHandler.anonymousFullAccessToken, - tool: "describe_table", - connectionId: connectionId, - sessionId: sessionId - ) - let result = try await bridge.fetchSchemaResource(connectionId: connectionId) - let jsonString = encodeJSON(result) - return MCPResourceReadResult(contents: [ - MCPResourceContent(uri: uri, mimeType: "application/json", text: jsonString) - ]) - } - - private func handleHistoryResource( - uri: String, - connectionId: UUID, - queryItems: [URLQueryItem], - sessionId: String - ) async throws -> MCPResourceReadResult { - try await authPolicy.resolveAndAuthorize( - token: MCPToolHandler.anonymousFullAccessToken, - tool: "search_query_history", - connectionId: connectionId, - sessionId: sessionId - ) - let limit = queryItems.first(where: { $0.name == "limit" }) - .flatMap { $0.value } - .flatMap { Int($0) } - ?? 50 - - let clampedLimit = min(max(limit, 1), 500) - let search = queryItems.first(where: { $0.name == "search" })?.value - let dateFilter = queryItems.first(where: { $0.name == "date_filter" })?.value - - let result = try await bridge.fetchHistoryResource( - connectionId: connectionId, - limit: clampedLimit, - search: search, - dateFilter: dateFilter - ) - let jsonString = encodeJSON(result) - return MCPResourceReadResult(contents: [ - MCPResourceContent(uri: uri, mimeType: "application/json", text: jsonString) - ]) - } - - private func parsePathSegments(from uri: String) -> [String] { - guard let range = uri.range(of: "://") else { return [] } - let afterScheme = String(uri[range.upperBound...]) - let pathOnly: String - if let queryStart = afterScheme.firstIndex(of: "?") { - pathOnly = String(afterScheme[.. String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - guard let data = try? encoder.encode(value), - let string = String(data: data, encoding: .utf8) - else { - Self.logger.warning("Failed to encode JSON value") - return "{}" - } - return string - } -} diff --git a/TablePro/Core/MCP/MCPRouteHandler.swift b/TablePro/Core/MCP/MCPRouteHandler.swift deleted file mode 100644 index 3b7a39c12..000000000 --- a/TablePro/Core/MCP/MCPRouteHandler.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -protocol MCPRouteHandler: Sendable { - var methods: [HTTPRequest.Method] { get } - var path: String { get } - func handle(_ request: HTTPRequest) async -> MCPRouter.RouteResult -} diff --git a/TablePro/Core/MCP/MCPRouter.swift b/TablePro/Core/MCP/MCPRouter.swift deleted file mode 100644 index 1561e27dd..000000000 --- a/TablePro/Core/MCP/MCPRouter.swift +++ /dev/null @@ -1,485 +0,0 @@ -import Foundation - -final class MCPRouter: Sendable { - enum RouteResult: Sendable { - case json(Data, sessionId: String?) - case sseStream(sessionId: String) - case accepted - case noContent - case httpError(status: Int, message: String) - case httpErrorWithHeaders(status: Int, message: String, extraHeaders: [(String, String)]) - } - - private let routes: [any MCPRouteHandler] - - init(routes: [any MCPRouteHandler]) { - self.routes = routes - } - - func handle(_ request: HTTPRequest) async -> RouteResult { - if request.path.hasPrefix("/.well-known/") { - return .httpError(status: 404, message: "Not found") - } - - if request.method == .options { - return .noContent - } - - guard let route = match(request) else { - return .httpError(status: 404, message: "Not found") - } - - return await route.handle(request) - } - - private func match(_ request: HTTPRequest) -> (any MCPRouteHandler)? { - let normalizedPath = Self.canonicalPath(request.path) - return routes.first { route in - route.path == normalizedPath && route.methods.contains(request.method) - } - } - - private static func canonicalPath(_ path: String) -> String { - if let queryIndex = path.firstIndex(of: "?") { - return String(path[.. [MCPToolDefinition] { - connectionTools() + schemaTools() + queryAndExportTools() + integrationTools() - } - - private static func connectionTools() -> [MCPToolDefinition] { - [ - MCPToolDefinition( - name: "list_connections", - description: "List all saved database connections with their status", - inputSchema: .object([ - "type": "object", - "properties": .object([:]), - "required": .array([]) - ]) - ), - MCPToolDefinition( - name: "connect", - description: "Connect to a saved database", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the saved connection" - ]) - ]), - "required": .array([.string("connection_id")]) - ]) - ), - MCPToolDefinition( - name: "disconnect", - description: "Disconnect from a database", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection to disconnect" - ]) - ]), - "required": .array([.string("connection_id")]) - ]) - ), - MCPToolDefinition( - name: "get_connection_status", - description: "Get detailed status of a database connection", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection" - ]) - ]), - "required": .array([.string("connection_id")]) - ]) - ), - MCPToolDefinition( - name: "switch_database", - description: "Switch the active database on a connection", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection" - ]), - "database": .object([ - "type": "string", - "description": "Database name to switch to" - ]) - ]), - "required": .array([.string("connection_id"), .string("database")]) - ]) - ), - MCPToolDefinition( - name: "switch_schema", - description: "Switch the active schema on a connection", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection" - ]), - "schema": .object([ - "type": "string", - "description": "Schema name to switch to" - ]) - ]), - "required": .array([.string("connection_id"), .string("schema")]) - ]) - ) - ] - } - - private static func schemaTools() -> [MCPToolDefinition] { - [ - MCPToolDefinition( - name: "list_databases", - description: "List all databases on the server", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection" - ]) - ]), - "required": .array([.string("connection_id")]) - ]) - ), - MCPToolDefinition( - name: "list_schemas", - description: "List schemas in a database", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection" - ]), - "database": .object([ - "type": "string", - "description": "Database name (uses current if omitted)" - ]) - ]), - "required": .array([.string("connection_id")]) - ]) - ), - MCPToolDefinition( - name: "list_tables", - description: "List tables and views in a database", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection" - ]), - "database": .object([ - "type": "string", - "description": "Database name (uses current if omitted)" - ]), - "schema": .object([ - "type": "string", - "description": "Schema name (uses current if omitted)" - ]), - "include_row_counts": .object([ - "type": "boolean", - "description": "Include approximate row counts (default false)" - ]) - ]), - "required": .array([.string("connection_id")]) - ]) - ), - MCPToolDefinition( - name: "describe_table", - description: "Get detailed table structure: columns, indexes, foreign keys, and DDL", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection" - ]), - "table": .object([ - "type": "string", - "description": "Table name" - ]), - "schema": .object([ - "type": "string", - "description": "Schema name (uses current if omitted)" - ]) - ]), - "required": .array([.string("connection_id"), .string("table")]) - ]) - ), - MCPToolDefinition( - name: "get_table_ddl", - description: "Get the CREATE TABLE DDL statement for a table", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection" - ]), - "table": .object([ - "type": "string", - "description": "Table name" - ]), - "schema": .object([ - "type": "string", - "description": "Schema name (uses current if omitted)" - ]) - ]), - "required": .array([.string("connection_id"), .string("table")]) - ]) - ) - ] - } - - private static func queryAndExportTools() -> [MCPToolDefinition] { - [ - MCPToolDefinition( - name: "execute_query", - description: "Execute a SQL query. All queries are subject to the connection's safe mode policy. " - + "DROP/TRUNCATE/ALTER...DROP must use the confirm_destructive_operation tool.", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection" - ]), - "query": .object([ - "type": "string", - "description": "SQL or NoSQL query text" - ]), - "max_rows": .object([ - "type": "integer", - "description": "Maximum rows to return (default 500, max 10000)" - ]), - "timeout_seconds": .object([ - "type": "integer", - "description": "Query timeout in seconds (default 30, max 300)" - ]), - "database": .object([ - "type": "string", - "description": "Switch to this database before executing" - ]), - "schema": .object([ - "type": "string", - "description": "Switch to this schema before executing" - ]) - ]), - "required": .array([.string("connection_id"), .string("query")]) - ]) - ), - MCPToolDefinition( - name: "export_data", - description: "Export query results or table data to CSV, JSON, or SQL", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection" - ]), - "format": .object([ - "type": "string", - "description": "Export format: csv, json, or sql", - "enum": .array([.string("csv"), .string("json"), .string("sql")]) - ]), - "query": .object([ - "type": "string", - "description": "SQL query to export results from" - ]), - "tables": .object([ - "type": "array", - "description": "Table names to export (alternative to query)", - "items": .object(["type": "string"]) - ]), - "output_path": .object([ - "type": "string", - "description": "File path inside the user's Downloads directory (returns inline data if omitted). Paths outside Downloads are rejected." - ]), - "max_rows": .object([ - "type": "integer", - "description": "Maximum rows to export (default 50000)" - ]) - ]), - "required": .array([.string("connection_id"), .string("format")]) - ]) - ), - MCPToolDefinition( - name: "confirm_destructive_operation", - description: "Execute a destructive DDL query (DROP, TRUNCATE, ALTER...DROP) after explicit confirmation.", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the active connection" - ]), - "query": .object([ - "type": "string", - "description": "The destructive query to execute" - ]), - "confirmation_phrase": .object([ - "type": "string", - "description": "Must be exactly: I understand this is irreversible" - ]) - ]), - "required": .array([ - .string("connection_id"), - .string("query"), - .string("confirmation_phrase") - ]) - ]) - ) - ] - } - - private static func integrationTools() -> [MCPToolDefinition] { - [ - MCPToolDefinition( - name: "list_recent_tabs", - description: "List currently open tabs across all TablePro windows. " - + "Returns connection, tab type, table name, and titles for each tab.", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "limit": .object([ - "type": "integer", - "description": "Maximum number of tabs to return (default 20, max 500)" - ]) - ]), - "required": .array([]) - ]) - ), - MCPToolDefinition( - name: "search_query_history", - description: "Search saved query history. " - + "Returns matching entries with execution time, row count, and outcome.", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "query": .object([ - "type": "string", - "description": "Search text (full-text matched against the query column)" - ]), - "connection_id": .object([ - "type": "string", - "description": "Restrict to a specific connection (UUID, optional)" - ]), - "limit": .object([ - "type": "integer", - "description": "Maximum number of entries to return (default 50, max 500)" - ]), - "since": .object([ - "type": "number", - "description": "Earliest executed_at to include, Unix epoch seconds (inclusive, optional)" - ]), - "until": .object([ - "type": "number", - "description": "Latest executed_at to include, Unix epoch seconds (inclusive, optional)" - ]) - ]), - "required": .array([.string("query")]) - ]) - ), - MCPToolDefinition( - name: "open_connection_window", - description: "Open a TablePro window for a saved connection (focuses if already open).", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the saved connection" - ]) - ]), - "required": .array([.string("connection_id")]) - ]) - ), - MCPToolDefinition( - name: "open_table_tab", - description: "Open a table tab in TablePro for the given connection.", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "connection_id": .object([ - "type": "string", - "description": "UUID of the connection" - ]), - "table_name": .object([ - "type": "string", - "description": "Table name to open" - ]), - "database_name": .object([ - "type": "string", - "description": "Database name (uses connection's current database if omitted)" - ]), - "schema_name": .object([ - "type": "string", - "description": "Schema name (for multi-schema databases)" - ]) - ]), - "required": .array([.string("connection_id"), .string("table_name")]) - ]) - ), - MCPToolDefinition( - name: "focus_query_tab", - description: "Focus an already-open tab by id (returned from list_recent_tabs).", - inputSchema: .object([ - "type": "object", - "properties": .object([ - "tab_id": .object([ - "type": "string", - "description": "UUID of the tab to focus" - ]) - ]), - "required": .array([.string("tab_id")]) - ]) - ) - ] - } -} - -extension MCPRouter { - static func resourceDefinitions() -> [MCPResourceDefinition] { - [ - MCPResourceDefinition( - uri: "tablepro://connections", - name: "Saved Connections", - description: "List of all saved database connections with metadata", - mimeType: "application/json" - ), - MCPResourceDefinition( - uri: "tablepro://connections/{id}/schema", - name: "Database Schema", - description: "Tables, columns, indexes, and foreign keys for a connected database", - mimeType: "application/json" - ), - MCPResourceDefinition( - uri: "tablepro://connections/{id}/history", - name: "Query History", - description: "Recent query history for a connection (supports ?limit=, ?search=, ?date_filter=)", - mimeType: "application/json" - ) - ] - } -} diff --git a/TablePro/Core/MCP/MCPServer.swift b/TablePro/Core/MCP/MCPServer.swift deleted file mode 100644 index 19258e1f1..000000000 --- a/TablePro/Core/MCP/MCPServer.swift +++ /dev/null @@ -1,481 +0,0 @@ -import Foundation -import Network -import os -import Security - -actor MCPServer { - struct SessionSnapshot: Sendable, Identifiable { - let id: String - let clientName: String - let clientVersion: String? - let connectedSince: Date - let lastActivityAt: Date - let tokenName: String? - let remoteAddress: String? - } - - private static let logger = Logger(subsystem: "com.TablePro", category: "MCPServer") - - private static let maxSessions = 10 - private static let idleTimeout: TimeInterval = 300 - private static let cleanupInterval: TimeInterval = 60 - private static let maxReadSize = 1_048_576 - private static let maxBufferSize = 10 * 1_024 * 1_024 - - private var allowRemoteAccess: Bool = false - private var listener: NWListener? - private var sessions: [String: LegacyMCPSession] = [:] - private var cleanupTask: Task? - private let stateCallback: @Sendable (MCPServerState) -> Void - private var router: MCPRouter? - - private(set) var tokenStore: MCPTokenStore? - private(set) var rateLimiter: LegacyMCPRateLimiter? - - private(set) var toolCallHandler: (@Sendable (String, JSONValue?, String, MCPAuthToken?) async throws -> MCPToolResult)? - private(set) var resourceReadHandler: (@Sendable (String, String) async throws -> MCPResourceReadResult)? - private(set) var sessionCleanupHandler: (@Sendable (String) async -> Void)? - - init(stateCallback: @escaping @Sendable (MCPServerState) -> Void) { - self.stateCallback = stateCallback - } - - func setRouter(_ router: MCPRouter) { - self.router = router - } - - func setTokenStore(_ store: MCPTokenStore) { - self.tokenStore = store - } - - func setRateLimiter(_ limiter: LegacyMCPRateLimiter) { - self.rateLimiter = limiter - } - - func setToolCallHandler(_ handler: @escaping @Sendable (String, JSONValue?, String, MCPAuthToken?) async throws -> MCPToolResult) { - self.toolCallHandler = handler - } - - func setResourceReadHandler(_ handler: @escaping @Sendable (String, String) async throws -> MCPResourceReadResult) { - self.resourceReadHandler = handler - } - - func setSessionCleanupHandler(_ handler: @escaping @Sendable (String) async -> Void) { - self.sessionCleanupHandler = handler - } - - func start(port: UInt16, allowRemoteAccess: Bool = false, tlsIdentity: SecIdentity? = nil) throws { - guard listener == nil else { - Self.logger.warning("Server already running, ignoring start request") - return - } - - stateCallback(.starting) - self.allowRemoteAccess = allowRemoteAccess - - let params: NWParameters - - if allowRemoteAccess, let identity = tlsIdentity { - let tlsOptions = NWProtocolTLS.Options() - guard let secIdentity = sec_identity_create(identity) else { - stateCallback(.failed("Failed to create TLS identity")) - return - } - sec_protocol_options_set_local_identity(tlsOptions.securityProtocolOptions, secIdentity) - sec_protocol_options_set_min_tls_protocol_version(tlsOptions.securityProtocolOptions, .TLSv12) - params = NWParameters(tls: tlsOptions, tcp: NWProtocolTCP.Options()) - params.requiredLocalEndpoint = NWEndpoint.hostPort( - host: .ipv4(.any), - port: NWEndpoint.Port(rawValue: port) ?? 23_508 - ) - params.allowLocalEndpointReuse = true - } else if allowRemoteAccess { - params = NWParameters.tcp - params.requiredLocalEndpoint = NWEndpoint.hostPort( - host: .ipv4(.any), - port: NWEndpoint.Port(rawValue: port) ?? 23_508 - ) - params.allowLocalEndpointReuse = true - } else { - params = NWParameters.tcp - params.requiredLocalEndpoint = NWEndpoint.hostPort( - host: .ipv4(.loopback), - port: NWEndpoint.Port(rawValue: port) ?? 23_508 - ) - params.allowLocalEndpointReuse = true - } - - let newListener = try NWListener(using: params) - self.listener = newListener - - newListener.stateUpdateHandler = { [weak self] state in - guard let self else { return } - Task { - await self.handleListenerState(state, listener: newListener) - } - } - - newListener.newConnectionHandler = { [weak self] connection in - guard let self else { return } - Task { - await self.handleNewConnection(connection) - } - } - - newListener.start(queue: .global(qos: .userInitiated)) - startCleanupTimer() - } - - func stop() async { - Self.logger.info("Stopping MCP server") - - cleanupTask?.cancel() - cleanupTask = nil - - let sessionIds = Array(sessions.keys) - for (_, session) in sessions { - await session.cancelAllTasks() - await session.cancelSSEConnection() - } - - if let cleanupHandler = sessionCleanupHandler { - for id in sessionIds { - await cleanupHandler(id) - } - } - - sessions.removeAll() - - if let currentListener = listener { - listener = nil - await withCheckedContinuation { (continuation: CheckedContinuation) in - currentListener.stateUpdateHandler = { state in - if case .cancelled = state { - continuation.resume() - } - } - currentListener.cancel() - } - } - } - - var sessionCount: Int { - sessions.count - } - - func sessionSnapshots() async -> [SessionSnapshot] { - let now = ContinuousClock.now - var snapshots: [SessionSnapshot] = [] - for (_, session) in sessions { - let info = await session.clientInfo - let created = await session.createdAt - let lastActive = await session.lastActivityAt - let sessionTokenName = await session.tokenName - let sessionRemoteAddress = await session.remoteAddress - let connectedElapsed = now - created - let activeElapsed = now - lastActive - snapshots.append(SessionSnapshot( - id: session.id, - clientName: info?.name ?? String(localized: "Unknown"), - clientVersion: info?.version, - connectedSince: Date.now - TimeInterval(connectedElapsed.components.seconds), - lastActivityAt: Date.now - TimeInterval(activeElapsed.components.seconds), - tokenName: sessionTokenName, - remoteAddress: sessionRemoteAddress - )) - } - return snapshots - } - - private func handleListenerState(_ state: NWListener.State, listener: NWListener) { - switch state { - case .ready: - let port = listener.port?.rawValue ?? 0 - let bindAddress = allowRemoteAccess ? "0.0.0.0" : "127.0.0.1" - Self.logger.info("MCP server listening on \(bindAddress):\(port)") - stateCallback(.running(port: port)) - - case .failed(let error): - Self.logger.error("MCP server listener failed: \(error.localizedDescription)") - stateCallback(.failed(error.localizedDescription)) - self.listener = nil - listener.cancel() - - case .cancelled: - Self.logger.debug("MCP server listener cancelled") - - default: - break - } - } - - private func handleNewConnection(_ connection: NWConnection) { - connection.stateUpdateHandler = { [weak self] state in - guard let self else { return } - switch state { - case .ready: - Task { - await self.readRequest(from: connection, buffer: Data()) - } - case .failed(let error): - Self.logger.debug("Connection failed: \(error.localizedDescription)") - connection.cancel() - default: - break - } - } - connection.start(queue: .global(qos: .userInitiated)) - } - - private func readRequest(from connection: NWConnection, buffer: Data) { - connection.receive(minimumIncompleteLength: 1, maximumLength: Self.maxReadSize) { [weak self] content, _, isComplete, error in - guard let self else { return } - - Task { - await self.processReceivedData( - connection: connection, - existingBuffer: buffer, - content: content, - isComplete: isComplete, - error: error - ) - } - } - } - - private func processReceivedData( - connection: NWConnection, - existingBuffer: Data, - content: Data?, - isComplete: Bool, - error: NWError? - ) { - if let error { - Self.logger.debug("Read error: \(error.localizedDescription)") - connection.cancel() - return - } - - var buffer = existingBuffer - if let content { - buffer.append(content) - } - - if buffer.count > Self.maxBufferSize { - Self.logger.warning("Request buffer exceeds \(Self.maxBufferSize) bytes, rejecting") - sendHTTPError(connection: connection, status: 413, message: "Request entity too large") - return - } - - let parseResult = MCPHTTPParser.parse(buffer) - - switch parseResult { - case .success(let request): - Task { - await self.handleHTTPRequest(request, connection: connection) - } - - case .failure(.incomplete): - if isComplete { - sendHTTPError(connection: connection, status: 400, message: "Incomplete request") - } else { - readRequest(from: connection, buffer: buffer) - } - - case .failure(.bodyTooLarge): - sendHTTPError(connection: connection, status: 400, message: "Request body too large") - - case .failure(let parseError): - Self.logger.warning("Parse error: \(String(describing: parseError))") - sendHTTPError(connection: connection, status: 400, message: "Malformed HTTP request") - } - } - - static let corsHeaders: [(String, String)] = [ - ("Access-Control-Allow-Origin", "http://localhost"), - ("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"), - ("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, mcp-protocol-version, Authorization"), - ("Access-Control-Expose-Headers", "Mcp-Session-Id"), - ("Access-Control-Max-Age", "86400") - ] - - private func handleHTTPRequest(_ request: HTTPRequest, connection: NWConnection) async { - let remoteIP: String? = { - guard let endpoint = connection.currentPath?.remoteEndpoint, - case .hostPort(let host, _) = endpoint else { return nil } - return "\(host)" - }() - - guard let router else { - sendHTTPError(connection: connection, status: 503, message: "Server not configured") - return - } - - let routedRequest = request.withRemoteIP(remoteIP) - let result = await router.handle(routedRequest) - - switch result { - case .json(let data, let sessionId): - sendJsonResponse(connection: connection, data: data, sessionId: sessionId) - - case .sseStream(let sessionId): - if let session = sessions[sessionId] { - await session.cancelSSEConnection() - await session.setSSEConnection(connection) - } - sendSseHeaders(connection: connection, sessionId: sessionId) - - case .accepted: - sendResponse(connection: connection, status: 202, headers: Self.corsHeaders, body: nil) - - case .noContent: - sendResponse(connection: connection, status: 204, headers: Self.corsHeaders, body: nil) - - case .httpError(let status, let message): - sendHTTPError(connection: connection, status: status, message: message) - - case .httpErrorWithHeaders(let status, let message, let extraHeaders): - sendHTTPErrorWithHeaders(connection: connection, status: status, message: message, extraHeaders: extraHeaders) - } - } - - func createSession() -> LegacyMCPSession? { - guard sessions.count < Self.maxSessions else { - Self.logger.warning("Maximum session limit reached (\(Self.maxSessions))") - return nil - } - - let session = LegacyMCPSession() - sessions[session.id] = session - Self.logger.info("Created session \(session.id) (total: \(self.sessions.count))") - return session - } - - func session(for sessionId: String) -> LegacyMCPSession? { - sessions[sessionId] - } - - func removeSession(_ sessionId: String) async { - guard let session = sessions.removeValue(forKey: sessionId) else { return } - await session.cancelAllTasks() - await session.cancelSSEConnection() - try? await session.transition(to: .terminated(reason: .removed)) - - if let cleanupHandler = sessionCleanupHandler { - await cleanupHandler(sessionId) - } - - Self.logger.info("Removed session \(sessionId) (total: \(self.sessions.count))") - } - - private func startCleanupTimer() { - cleanupTask?.cancel() - cleanupTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(Self.cleanupInterval)) - guard !Task.isCancelled else { break } - await self?.cleanupIdleSessions() - } - } - } - - private func cleanupIdleSessions() async { - let now = ContinuousClock.now - var removed: [String] = [] - - for (id, session) in sessions { - let lastActivity = await session.lastActivityAt - let idle = now - lastActivity - if idle > .seconds(Self.idleTimeout) { - await session.cancelAllTasks() - await session.cancelSSEConnection() - try? await session.transition(to: .terminated(reason: .idleTimeout)) - sessions.removeValue(forKey: id) - - if let cleanupHandler = sessionCleanupHandler { - await cleanupHandler(id) - } - - removed.append(id) - } - } - - if !removed.isEmpty { - Self.logger.info("Cleaned up \(removed.count) idle session(s)") - } - } - - func sendResponse(connection: NWConnection, status: Int, headers: [(String, String)], body: Data?) { - let statusText = MCPHTTPParser.statusText(for: status) - let responseData = MCPHTTPParser.buildResponse( - status: status, - statusText: statusText, - headers: headers, - body: body - ) - connection.send(content: responseData, completion: .contentProcessed { error in - if let error { - Self.logger.debug("Send error: \(error.localizedDescription)") - } - if status != 200 || headers.contains(where: { $0.0.lowercased() == "connection" && $0.1.lowercased() == "close" }) { - connection.cancel() - } - }) - } - - func sendJsonResponse(connection: NWConnection, data: Data, sessionId: String?) { - var headers: [(String, String)] = [ - ("Content-Type", "application/json"), - ("Connection", "close") - ] - headers.append(contentsOf: Self.corsHeaders) - if let sessionId { - headers.append(("Mcp-Session-Id", sessionId)) - } - sendResponse(connection: connection, status: 200, headers: headers, body: data) - } - - func sendSseHeaders(connection: NWConnection, sessionId: String) { - let headerData = MCPHTTPParser.buildSSEHeaders( - sessionId: sessionId, - corsHeaders: Self.corsHeaders - ) - connection.send(content: headerData, completion: .contentProcessed { error in - if let error { - Self.logger.debug("SSE header send error: \(error.localizedDescription)") - } - }) - } - - func sendSseEvent(connection: NWConnection, data: Data, eventId: String? = nil) { - let eventData = MCPHTTPParser.buildSSEEvent(data: data, id: eventId) - connection.send(content: eventData, completion: .contentProcessed { error in - if let error { - Self.logger.debug("SSE event send error: \(error.localizedDescription)") - } - }) - } - - func sendHTTPError(connection: NWConnection, status: Int, message: String) { - let body: [String: String] = ["error": message] - let data = (try? JSONEncoder().encode(body)) ?? Data() - var headers: [(String, String)] = [ - ("Content-Type", "application/json"), - ("Connection", "close") - ] - headers.append(contentsOf: Self.corsHeaders) - sendResponse(connection: connection, status: status, headers: headers, body: data) - } - - func sendHTTPErrorWithHeaders(connection: NWConnection, status: Int, message: String, extraHeaders: [(String, String)]) { - let body: [String: String] = ["error": message] - let data = (try? JSONEncoder().encode(body)) ?? Data() - var headers: [(String, String)] = [ - ("Content-Type", "application/json"), - ("Connection", "close") - ] - headers.append(contentsOf: extraHeaders) - headers.append(contentsOf: Self.corsHeaders) - sendResponse(connection: connection, status: status, headers: headers, body: data) - } -} diff --git a/TablePro/Core/MCP/MCPServerManager.swift b/TablePro/Core/MCP/MCPServerManager.swift index 37b676234..d08770cb4 100644 --- a/TablePro/Core/MCP/MCPServerManager.swift +++ b/TablePro/Core/MCP/MCPServerManager.swift @@ -11,19 +11,36 @@ enum MCPServerState: Sendable, Equatable { @MainActor @Observable final class MCPServerManager { + struct SessionSnapshot: Sendable, Identifiable { + let id: String + let clientName: String + let clientVersion: String? + let connectedSince: Date + let lastActivityAt: Date + let tokenName: String? + let remoteAddress: String? + } + private static let logger = Logger(subsystem: "com.TablePro", category: "MCPServerManager") static let shared = MCPServerManager() private(set) var state: MCPServerState = .stopped - private(set) var connectedClients: [MCPServer.SessionSnapshot] = [] - private var server: MCPServer? - private var clientRefreshTask: Task? - private var serverGeneration: Int = 0 + private(set) var connectedClients: [SessionSnapshot] = [] private(set) var tokenStore: MCPTokenStore? + + private var transport: MCPHttpServerTransport? + private var dispatcher: MCPProtocolDispatcher? + private var sessionStore: MCPSessionStore? + private var rateLimiter: MCPRateLimiter? + private var dispatchTask: Task? + private var stateTask: Task? + private var sessionEventsTask: Task? + private var clientRefreshTask: Task? private var tlsManager: MCPTLSManager? private var bridgeTokenId: UUID? private var internalBridgeToken: String? + private var serverGeneration: Int = 0 var isRunning: Bool { if case .running = state { return true } else { return false } @@ -31,109 +48,107 @@ final class MCPServerManager { var connectedClientCount: Int { get async { - guard let server else { return 0 } - return await server.sessionCount + guard let sessionStore else { return 0 } + return await sessionStore.count() } } private init() {} func start(port: UInt16) async { - if server != nil { + if transport != nil { await stop() } serverGeneration += 1 let generation = serverGeneration - let newServer = MCPServer { [weak self] newState in - Task { @MainActor in - guard let self, self.serverGeneration == generation else { return } - self.state = newState - } - } - - self.server = newServer + state = .starting let newTokenStore = MCPTokenStore() await newTokenStore.loadFromDisk() - self.tokenStore = newTokenStore + tokenStore = newTokenStore - let rateLimiter = LegacyMCPRateLimiter() + let bridgeResult = await newTokenStore.generate( + name: MCPTokenStore.stdioBridgeTokenName, + permissions: .fullAccess + ) + bridgeTokenId = bridgeResult.token.id + internalBridgeToken = bridgeResult.plaintext - let bridge = MCPConnectionBridge() - let authPolicy = MCPAuthPolicy() - let toolHandler = MCPToolHandler(bridge: bridge, authPolicy: authPolicy) - let resourceHandler = MCPResourceHandler(bridge: bridge, authPolicy: authPolicy) + let settings = AppSettingsManager.shared.mcp + let configuration: MCPHttpServerConfiguration + do { + configuration = try await makeConfiguration(port: port, settings: settings) + } catch { + Self.logger.error("MCP TLS configuration failed: \(error.localizedDescription, privacy: .public)") + state = .failed("TLS certificate generation failed") + await cleanupBridgeToken() + tokenStore = nil + return + } - await newServer.setTokenStore(newTokenStore) - await newServer.setRateLimiter(rateLimiter) + let newSessionStore = MCPSessionStore(policy: .standard) + await newSessionStore.startCleanup() + sessionStore = newSessionStore - await newServer.setToolCallHandler { name, arguments, sessionId, token in - try await toolHandler.handleToolCall(name: name, arguments: arguments, sessionId: sessionId, token: token) - } - await newServer.setResourceReadHandler { uri, sessionId in - try await resourceHandler.handleResourceRead(uri: uri, sessionId: sessionId) - } - await newServer.setSessionCleanupHandler { sessionId in - await authPolicy.clearSession(sessionId) - } + let newRateLimiter = MCPRateLimiter() + rateLimiter = newRateLimiter - let protocolHandler = MCPProtocolHandler( - server: newServer, + let authenticator = MCPBearerTokenAuthenticator( tokenStore: newTokenStore, - rateLimiter: rateLimiter + rateLimiter: newRateLimiter ) - let exchangeHandler = IntegrationsExchangeHandler.live() - let router = MCPRouter(routes: [protocolHandler, exchangeHandler]) - await newServer.setRouter(router) - let bridgeResult = await newTokenStore.generate( - name: MCPTokenStore.stdioBridgeTokenName, - permissions: .fullAccess + let newTransport = MCPHttpServerTransport( + configuration: configuration, + sessionStore: newSessionStore, + authenticator: authenticator ) - self.bridgeTokenId = bridgeResult.token.id - self.internalBridgeToken = bridgeResult.plaintext + transport = newTransport - do { - let settings = AppSettingsManager.shared.mcp - - var tlsIdentity: SecIdentity? - if settings.allowRemoteConnections { - let manager = MCPTLSManager() - self.tlsManager = manager - do { - tlsIdentity = try await manager.loadOrGenerate() - } catch { - Self.logger.error("Failed to generate TLS certificate: \(error.localizedDescription)") - state = .failed("TLS certificate generation failed") - return - } - } + let progressSink = TransportProgressSink(transport: newTransport) + let services = MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: MCPAuthPolicy() + ) - try await newServer.start( - port: port, - allowRemoteAccess: settings.allowRemoteConnections, - tlsIdentity: tlsIdentity - ) - let certFingerprint = await tlsManager?.fingerprint - writeHandshakeFile(port: port, tlsCertFingerprint: certFingerprint) + let handlers: [any MCPMethodHandler] = [ + InitializeHandler(), + PingHandler(), + ToolsListHandler(), + ToolsCallHandler(services: services), + ResourcesListHandler(services: services), + ResourcesReadHandler(services: services), + ResourcesTemplatesListHandler(), + PromptsListHandler(), + PromptsGetHandler(), + LoggingSetLevelHandler(), + CompletionCompleteHandler() + ] + + let newDispatcher = MCPProtocolDispatcher( + handlers: handlers, + sessionStore: newSessionStore, + progressSink: progressSink + ) + dispatcher = newDispatcher + + startDispatchLoop(transport: newTransport, dispatcher: newDispatcher, generation: generation) + startStateLoop(transport: newTransport, generation: generation) + startSessionEventsLoop(sessionStore: newSessionStore, generation: generation) + + do { + try await newTransport.start() startClientRefresh() MCPAuditLogger.logServerStarted( port: port, remoteAccess: settings.allowRemoteConnections, - tlsEnabled: tlsIdentity != nil + tlsEnabled: configuration.tls != nil ) } catch { - Self.logger.error("Failed to start MCP server: \(error.localizedDescription)") + Self.logger.error("Failed to start MCP server: \(error.localizedDescription, privacy: .public)") state = .failed(error.localizedDescription) - if let bridgeId = bridgeTokenId { - await tokenStore?.delete(tokenId: bridgeId) - bridgeTokenId = nil - } - server = nil - self.tokenStore = nil - self.tlsManager = nil - self.internalBridgeToken = nil + await teardown() } } @@ -141,16 +156,7 @@ final class MCPServerManager { stopClientRefresh() deleteHandshakeFile() MCPAuditLogger.logServerStopped() - guard let server else { return } - await server.stop() - if let bridgeId = bridgeTokenId { - await tokenStore?.delete(tokenId: bridgeId) - bridgeTokenId = nil - } - self.server = nil - self.tokenStore = nil - self.tlsManager = nil - self.internalBridgeToken = nil + await teardown() state = .stopped } @@ -173,7 +179,7 @@ final class MCPServerManager { do { chosenPort = try MCPPortAllocator.findFreePort(in: 51_000...52_000) } catch { - Self.logger.error("Lazy start failed to allocate port: \(error.localizedDescription)") + Self.logger.error("Lazy start failed to allocate port: \(error.localizedDescription, privacy: .public)") state = .failed(error.localizedDescription) return } @@ -183,10 +189,123 @@ final class MCPServerManager { } func disconnectClient(_ sessionId: String) async { - await server?.removeSession(sessionId) + guard let sessionStore else { return } + await sessionStore.terminate(id: MCPSessionId(sessionId), reason: .clientRequested) await refreshClients() } + private func makeConfiguration( + port: UInt16, + settings: MCPSettings + ) async throws -> MCPHttpServerConfiguration { + if settings.allowRemoteConnections { + let manager = MCPTLSManager() + tlsManager = manager + let identity = try await manager.loadOrGenerate() + let tls = MCPTLSConfiguration(identity: identity) + return .remote(port: port, tls: tls) + } + return .loopback(port: port) + } + + private func startDispatchLoop( + transport: MCPHttpServerTransport, + dispatcher: MCPProtocolDispatcher, + generation: Int + ) { + dispatchTask?.cancel() + dispatchTask = Task { [weak self] in + for await exchange in transport.exchanges { + guard let self else { return } + guard await self.isCurrentGeneration(generation) else { return } + await dispatcher.dispatch(exchange) + } + } + } + + private func startStateLoop(transport: MCPHttpServerTransport, generation: Int) { + stateTask?.cancel() + stateTask = Task { [weak self] in + for await transportState in transport.listenerState { + guard let self else { return } + await self.applyTransportState(transportState, generation: generation) + } + } + } + + private func startSessionEventsLoop(sessionStore: MCPSessionStore, generation: Int) { + sessionEventsTask?.cancel() + sessionEventsTask = Task { [weak self] in + let stream = await sessionStore.events + for await event in stream { + guard let self else { return } + guard await self.isCurrentGeneration(generation) else { return } + Self.logger.debug("Session event: \(String(describing: event), privacy: .public)") + await self.refreshClients() + } + } + } + + private func isCurrentGeneration(_ generation: Int) -> Bool { + serverGeneration == generation + } + + private func applyTransportState(_ transportState: MCPHttpServerState, generation: Int) { + guard isCurrentGeneration(generation) else { return } + switch transportState { + case .idle: + state = .stopped + case .starting: + state = .starting + case .running(let port): + state = .running(port: port) + Task { [weak self] in + guard let self else { return } + let fingerprint = await self.tlsManager?.fingerprint + self.writeHandshakeFile(port: port, tlsCertFingerprint: fingerprint) + } + case .stopped: + state = .stopped + case .failed(let reason): + state = .failed(reason) + } + } + + private func teardown() async { + dispatchTask?.cancel() + dispatchTask = nil + stateTask?.cancel() + stateTask = nil + sessionEventsTask?.cancel() + sessionEventsTask = nil + + if let transport { + await transport.stop() + } + transport = nil + + if let sessionStore { + await sessionStore.shutdown(reason: .serverShutdown) + } + sessionStore = nil + + dispatcher = nil + rateLimiter = nil + tlsManager = nil + + await cleanupBridgeToken() + tokenStore = nil + connectedClients = [] + } + + private func cleanupBridgeToken() async { + if let bridgeId = bridgeTokenId { + await tokenStore?.delete(tokenId: bridgeId) + bridgeTokenId = nil + } + internalBridgeToken = nil + } + private func startClientRefresh() { clientRefreshTask = Task { [weak self] in while !Task.isCancelled { @@ -203,11 +322,16 @@ final class MCPServerManager { } private func refreshClients() async { - guard let server else { + guard let sessionStore else { connectedClients = [] return } - connectedClients = await server.sessionSnapshots() + let snapshots = await collectSessionSnapshots(from: sessionStore) + connectedClients = snapshots + } + + private func collectSessionSnapshots(from store: MCPSessionStore) async -> [SessionSnapshot] { + await store.snapshotsForUI() } private static let handshakeDirectoryPath: String = { @@ -227,7 +351,7 @@ final class MCPServerManager { "port": Int(port), "token": bridgeToken, "pid": ProcessInfo.processInfo.processIdentifier, - "protocolVersion": "2025-03-26", + "protocolVersion": InitializeHandler.supportedProtocolVersion, "tls": settings.allowRemoteConnections ] if let tlsCertFingerprint { @@ -257,9 +381,9 @@ final class MCPServerManager { ofItemAtPath: Self.handshakeFilePath ) - Self.logger.info("Wrote MCP handshake file at \(Self.handshakeFilePath)") + Self.logger.info("Wrote MCP handshake file at \(Self.handshakeFilePath, privacy: .public)") } catch { - Self.logger.error("Failed to write MCP handshake file: \(error.localizedDescription)") + Self.logger.error("Failed to write MCP handshake file: \(error.localizedDescription, privacy: .public)") } } @@ -271,7 +395,35 @@ final class MCPServerManager { try fileManager.removeItem(atPath: Self.handshakeFilePath) Self.logger.info("Deleted MCP handshake file") } catch { - Self.logger.error("Failed to delete MCP handshake file: \(error.localizedDescription)") + Self.logger.error("Failed to delete MCP handshake file: \(error.localizedDescription, privacy: .public)") + } + } +} + +private struct TransportProgressSink: MCPProgressSink { + let transport: MCPHttpServerTransport + + func sendNotification(_ notification: JsonRpcNotification, toSession sessionId: MCPSessionId) async { + await transport.sendNotification(notification, toSession: sessionId) + } +} + +private extension MCPSessionStore { + func snapshotsForUI() async -> [MCPServerManager.SessionSnapshot] { + var result: [MCPServerManager.SessionSnapshot] = [] + for session in await allSessions() { + let snapshot = await session.snapshot() + let info = snapshot.clientInfo + result.append(MCPServerManager.SessionSnapshot( + id: snapshot.id.rawValue, + clientName: info?.name ?? String(localized: "Unknown"), + clientVersion: info?.version, + connectedSince: snapshot.createdAt, + lastActivityAt: snapshot.lastActivityAt, + tokenName: nil, + remoteAddress: nil + )) } + return result } } diff --git a/TablePro/Core/MCP/MCPSession.swift b/TablePro/Core/MCP/MCPSession.swift deleted file mode 100644 index a52295cad..000000000 --- a/TablePro/Core/MCP/MCPSession.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -import Network - -actor MCPSession { - let id: String - let createdAt: ContinuousClock.Instant - - var lastActivityAt: ContinuousClock.Instant - private(set) var phase: MCPSessionPhase = .created - var clientInfo: MCPClientInfo? - var sseConnection: NWConnection? - var runningTasks: [JSONRPCId: Task] = [:] - private(set) var eventCounter: Int = 0 - private(set) var remoteAddress: String? - - var authenticatedTokenId: UUID? { - if case .active(let tokenId, _) = phase { return tokenId } - return nil - } - - var tokenName: String? { - if case .active(_, let tokenName) = phase { return tokenName } - return nil - } - - init() { - self.id = UUID().uuidString - let now = ContinuousClock.now - self.createdAt = now - self.lastActivityAt = now - } - - func markActive() { - lastActivityAt = .now - } - - func cancelAllTasks() { - for (_, task) in runningTasks { - task.cancel() - } - runningTasks.removeAll() - } - - func transition(to next: MCPSessionPhase) throws { - guard isValidTransition(from: phase, to: next) else { - throw MCPError.invalidRequest( - "Invalid session phase transition from \(phase) to \(next)" - ) - } - phase = next - } - - private func isValidTransition(from current: MCPSessionPhase, to next: MCPSessionPhase) -> Bool { - switch (current, next) { - case (.created, .initializing), - (.created, .active), - (.created, .terminated), - (.initializing, .active), - (.initializing, .terminated), - (.active, .terminated): - return true - default: - return false - } - } - - func setClientInfo(_ info: MCPClientInfo?) { - clientInfo = info - } - - func setRemoteAddress(_ address: String?) { - remoteAddress = address - } - - func setSSEConnection(_ connection: NWConnection?) { - sseConnection = connection - } - - func cancelSSEConnection() { - sseConnection?.cancel() - } - - func addRunningTask(_ id: JSONRPCId, task: Task) { - runningTasks[id] = task - } - - func removeRunningTask(_ id: JSONRPCId) -> Task? { - runningTasks.removeValue(forKey: id) - } - - func nextEventId() -> String { - eventCounter += 1 - return String(eventCounter) - } -} diff --git a/TablePro/Core/MCP/MCPSessionPhase.swift b/TablePro/Core/MCP/MCPSessionPhase.swift deleted file mode 100644 index 795c8c67b..000000000 --- a/TablePro/Core/MCP/MCPSessionPhase.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation - -enum LegacyMCPSessionTerminationReason: Sendable, Equatable { - case removed - case idleTimeout - case serverStopped - case clientDisconnected -} - -enum MCPSessionPhase: Sendable, Equatable { - case created - case initializing - case active(tokenId: UUID?, tokenName: String?) - case terminated(reason: LegacyMCPSessionTerminationReason) - - var isActive: Bool { - if case .active = self { return true } - return false - } -} diff --git a/TablePro/Core/MCP/MCPToolHandler+Integrations.swift b/TablePro/Core/MCP/MCPToolHandler+Integrations.swift deleted file mode 100644 index df53c0431..000000000 --- a/TablePro/Core/MCP/MCPToolHandler+Integrations.swift +++ /dev/null @@ -1,334 +0,0 @@ -// -// MCPToolHandler+Integrations.swift -// TablePro -// - -import AppKit -import Foundation - -extension MCPToolHandler { - func handleListRecentTabs(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let limit = optionalInt(args, key: "limit", default: 20, clamp: 1...500) - - if let token, !token.permissions.satisfies(.readOnly) { - throw MCPError.forbidden( - "Token '\(token.name)' with permission '\(token.permissions.displayName)' cannot access 'list_recent_tabs'" - ) - } - - let snapshots = await MainActor.run { Self.collectTabSnapshots() } - let blockedConnectionIds = await MainActor.run { Self.blockedExternalConnectionIds() } - let access = token?.connectionAccess ?? .all - let filtered = snapshots.filter { snapshot in - guard !blockedConnectionIds.contains(snapshot.connectionId) else { return false } - return access.allows(snapshot.connectionId) - } - - let trimmed = Array(filtered.prefix(limit)) - let payload = trimmed.map { snapshot -> JSONValue in - var dict: [String: JSONValue] = [ - "connection_id": .string(snapshot.connectionId.uuidString), - "connection_name": .string(snapshot.connectionName), - "tab_id": .string(snapshot.tabId.uuidString), - "tab_type": .string(snapshot.tabType), - "display_title": .string(snapshot.displayTitle), - "is_active": .bool(snapshot.isActive) - ] - if let table = snapshot.tableName { - dict["table_name"] = .string(table) - } - if let database = snapshot.databaseName { - dict["database_name"] = .string(database) - } - if let schema = snapshot.schemaName { - dict["schema_name"] = .string(schema) - } - if let windowId = snapshot.windowId { - dict["window_id"] = .string(windowId.uuidString) - } - return .object(dict) - } - - return MCPToolResult(content: [.text(encodeJSON(.object(["tabs": .array(payload)])))], isError: nil) - } - - func handleSearchQueryHistory(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let query = try requireString(args, key: "query") - let connectionIdString = optionalString(args, key: "connection_id") - let limit = optionalInt(args, key: "limit", default: 50, clamp: 1...500) - let since = args?["since"]?.doubleValue.map { Date(timeIntervalSince1970: $0) } - let until = args?["until"]?.doubleValue.map { Date(timeIntervalSince1970: $0) } - - if let since, let until, since > until { - throw MCPError.invalidParams("'since' must be less than or equal to 'until'") - } - - if let token, !token.permissions.satisfies(.readOnly) { - throw MCPError.forbidden( - "Token '\(token.name)' with permission '\(token.permissions.displayName)' cannot access 'search_query_history'" - ) - } - - let blockedConnectionIds = await MainActor.run { Self.blockedExternalConnectionIds() } - - let connectionId: UUID? - if let connectionIdString { - guard let parsed = UUID(uuidString: connectionIdString) else { - throw MCPError.invalidParams("Invalid UUID for parameter: connection_id") - } - if let token, !token.connectionAccess.allows(parsed) { - throw MCPError.forbidden("Token does not have access to this connection") - } - if blockedConnectionIds.contains(parsed) { - throw MCPError.forbidden( - String(localized: "External access is disabled for this connection") - ) - } - connectionId = parsed - } else { - connectionId = nil - } - - let tokenScopedAllowlist = await resolveHistoryAllowlist( - token: token, - scopedConnectionId: connectionId, - blockedConnectionIds: blockedConnectionIds - ) - - let entries = await QueryHistoryStorage.shared.fetchHistory( - limit: limit, - offset: 0, - connectionId: connectionId, - searchText: query.isEmpty ? nil : query, - dateFilter: .all, - since: since, - until: until, - allowedConnectionIds: tokenScopedAllowlist - ) - - let payload = entries.map { entry -> JSONValue in - var dict: [String: JSONValue] = [ - "id": .string(entry.id.uuidString), - "query": .string(entry.query), - "connection_id": .string(entry.connectionId.uuidString), - "database_name": .string(entry.databaseName), - "executed_at": .double(entry.executedAt.timeIntervalSince1970), - "execution_time_ms": .double(entry.executionTime * 1_000), - "row_count": .int(entry.rowCount), - "was_successful": .bool(entry.wasSuccessful) - ] - if let error = entry.errorMessage { - dict["error_message"] = .string(error) - } - return .object(dict) - } - - return MCPToolResult(content: [.text(encodeJSON(.object(["entries": .array(payload)])))], isError: nil) - } - - func handleOpenConnectionWindow(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - try await ensureConnectionExists(connectionId) - try await authPolicy.resolveAndAuthorize( - token: token ?? Self.anonymousFullAccessToken, - tool: "open_connection_window", - connectionId: connectionId, - sessionId: sessionId - ) - - let windowId = await MainActor.run { () -> UUID in - let payload = EditorTabPayload( - connectionId: connectionId, - tabType: .query, - intent: .restoreOrDefault - ) - WindowManager.shared.openTab(payload: payload) - NSApp.activate(ignoringOtherApps: true) - return payload.id - } - - let result: JSONValue = .object([ - "status": "opened", - "connection_id": .string(connectionId.uuidString), - "window_id": .string(windowId.uuidString) - ]) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - func handleOpenTableTab(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - let tableName = try requireString(args, key: "table_name") - let databaseName = optionalString(args, key: "database_name") - let schemaName = optionalString(args, key: "schema_name") - - try await ensureConnectionExists(connectionId) - try await authPolicy.resolveAndAuthorize( - token: token ?? Self.anonymousFullAccessToken, - tool: "open_table_tab", - connectionId: connectionId, - sessionId: sessionId - ) - - let windowId = await MainActor.run { () -> UUID in - let payload = EditorTabPayload( - connectionId: connectionId, - tabType: .table, - tableName: tableName, - databaseName: databaseName, - schemaName: schemaName, - intent: .openContent - ) - WindowManager.shared.openTab(payload: payload) - NSApp.activate(ignoringOtherApps: true) - return payload.id - } - - let result: JSONValue = .object([ - "status": "opened", - "connection_id": .string(connectionId.uuidString), - "table_name": .string(tableName), - "window_id": .string(windowId.uuidString) - ]) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - func handleFocusQueryTab(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let tabId = try requireUUID(args, key: "tab_id") - - let resolved = await MainActor.run { () -> (hasWindow: Bool, windowId: UUID?, connectionId: UUID?)? in - for snapshot in Self.collectTabSnapshots() where snapshot.tabId == tabId { - return (snapshot.window != nil, snapshot.windowId, snapshot.connectionId) - } - return nil - } - - guard let resolved, resolved.hasWindow else { - throw MCPError.notFound("tab") - } - - guard let connectionId = resolved.connectionId else { - throw MCPError.notFound("connection") - } - try await authPolicy.resolveAndAuthorize( - token: token ?? Self.anonymousFullAccessToken, - tool: "focus_query_tab", - connectionId: connectionId, - sessionId: sessionId - ) - - let raised = await MainActor.run { () -> Bool in - for snapshot in Self.collectTabSnapshots() where snapshot.tabId == tabId { - guard snapshot.connectionId == connectionId else { return false } - guard let window = snapshot.window else { return false } - NSApp.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - return true - } - return false - } - - guard raised else { - throw MCPError.notFound("tab") - } - - var dict: [String: JSONValue] = [ - "status": "focused", - "tab_id": .string(tabId.uuidString), - "connection_id": .string(connectionId.uuidString) - ] - if let windowId = resolved.windowId { - dict["window_id"] = .string(windowId.uuidString) - } - - return MCPToolResult(content: [.text(encodeJSON(.object(dict)))], isError: nil) - } - - private func resolveHistoryAllowlist( - token: MCPAuthToken?, - scopedConnectionId: UUID?, - blockedConnectionIds: Set - ) async -> Set? { - if scopedConnectionId != nil { - return nil - } - if let access = token?.connectionAccess, case .limited(let allowed) = access { - return allowed.subtracting(blockedConnectionIds) - } - guard !blockedConnectionIds.isEmpty else { return nil } - let allConnectionIds = await MainActor.run { - Set(ConnectionStorage.shared.loadConnections().map(\.id)) - } - return allConnectionIds.subtracting(blockedConnectionIds) - } - - private func ensureConnectionExists(_ connectionId: UUID) async throws { - let exists = await MainActor.run { - ConnectionStorage.shared.loadConnections().contains { $0.id == connectionId } - } - guard exists else { - throw MCPError.notFound("connection") - } - } - - @MainActor - static func collectTabSnapshots() -> [TabSnapshot] { - let connections = ConnectionStorage.shared.loadConnections() - let connectionsById = Dictionary(uniqueKeysWithValues: connections.map { ($0.id, $0) }) - - var snapshots: [TabSnapshot] = [] - for coordinator in MainContentCoordinator.allActiveCoordinators() { - let connectionName = connectionsById[coordinator.connectionId]?.name - ?? coordinator.connection.name - let selectedId = coordinator.tabManager.selectedTabId - for tab in coordinator.tabManager.tabs { - snapshots.append(TabSnapshot( - tabId: tab.id, - connectionId: coordinator.connectionId, - connectionName: connectionName, - tabType: tab.tabType.snapshotName, - tableName: tab.tableContext.tableName, - databaseName: tab.tableContext.databaseName, - schemaName: tab.tableContext.schemaName, - displayTitle: tab.title, - windowId: coordinator.windowId, - isActive: tab.id == selectedId, - window: coordinator.contentWindow - )) - } - } - return snapshots - } - - @MainActor - static func blockedExternalConnectionIds() -> Set { - let connections = ConnectionStorage.shared.loadConnections() - return Set(connections.filter { $0.externalAccess == .blocked }.map(\.id)) - } -} - -struct TabSnapshot { - let tabId: UUID - let connectionId: UUID - let connectionName: String - let tabType: String - let tableName: String? - let databaseName: String? - let schemaName: String? - let displayTitle: String - let windowId: UUID? - let isActive: Bool - weak var window: NSWindow? -} - -private extension TabType { - var snapshotName: String { - switch self { - case .query: "query" - case .table: "table" - case .createTable: "createTable" - case .erDiagram: "erDiagram" - case .serverDashboard: "serverDashboard" - case .terminal: "terminal" - } - } -} diff --git a/TablePro/Core/MCP/MCPToolHandler.swift b/TablePro/Core/MCP/MCPToolHandler.swift deleted file mode 100644 index 045c8ac0a..000000000 --- a/TablePro/Core/MCP/MCPToolHandler.swift +++ /dev/null @@ -1,796 +0,0 @@ -import Foundation -import os - -final class MCPToolHandler: Sendable { - private static let logger = Logger(subsystem: "com.TablePro", category: "MCPToolHandler") - - let bridge: MCPConnectionBridge - let authPolicy: MCPAuthPolicy - - init(bridge: MCPConnectionBridge, authPolicy: MCPAuthPolicy) { - self.bridge = bridge - self.authPolicy = authPolicy - } - - func handleToolCall( - name: String, - arguments: JSONValue?, - sessionId: String, - token: MCPAuthToken? = nil - ) async throws -> MCPToolResult { - do { - let result = try await dispatchTool( - name: name, - arguments: arguments, - sessionId: sessionId, - token: token - ) - logToolOutcome(name: name, token: token, arguments: arguments, outcome: .success, error: nil) - return result - } catch let error as MCPError { - let outcome: AuditOutcome - if case .forbidden = error { - outcome = .denied - } else { - outcome = .error - } - logToolOutcome(name: name, token: token, arguments: arguments, outcome: outcome, error: error.message) - throw error - } catch { - logToolOutcome(name: name, token: token, arguments: arguments, outcome: .error, error: error.localizedDescription) - throw error - } - } - - private func dispatchTool( - name: String, - arguments: JSONValue?, - sessionId: String, - token: MCPAuthToken? - ) async throws -> MCPToolResult { - switch name { - case "list_connections": - return try await handleListConnections(token: token) - case "connect": - return try await handleConnect(arguments, sessionId: sessionId, token: token) - case "disconnect": - return try await handleDisconnect(arguments, sessionId: sessionId, token: token) - case "get_connection_status": - return try await handleGetConnectionStatus(arguments, sessionId: sessionId, token: token) - case "execute_query": - return try await handleExecuteQuery(arguments, sessionId: sessionId, token: token) - case "list_tables": - return try await handleListTables(arguments, sessionId: sessionId, token: token) - case "describe_table": - return try await handleDescribeTable(arguments, sessionId: sessionId, token: token) - case "list_databases": - return try await handleListDatabases(arguments, sessionId: sessionId, token: token) - case "list_schemas": - return try await handleListSchemas(arguments, sessionId: sessionId, token: token) - case "get_table_ddl": - return try await handleGetTableDDL(arguments, sessionId: sessionId, token: token) - case "export_data": - return try await handleExportData(arguments, sessionId: sessionId, token: token) - case "confirm_destructive_operation": - return try await handleConfirmDestructiveOperation(arguments, sessionId: sessionId, token: token) - case "switch_database": - return try await handleSwitchDatabase(arguments, sessionId: sessionId, token: token) - case "switch_schema": - return try await handleSwitchSchema(arguments, sessionId: sessionId, token: token) - case "list_recent_tabs": - return try await handleListRecentTabs(arguments, sessionId: sessionId, token: token) - case "search_query_history": - return try await handleSearchQueryHistory(arguments, sessionId: sessionId, token: token) - case "open_connection_window": - return try await handleOpenConnectionWindow(arguments, sessionId: sessionId, token: token) - case "open_table_tab": - return try await handleOpenTableTab(arguments, sessionId: sessionId, token: token) - case "focus_query_tab": - return try await handleFocusQueryTab(arguments, sessionId: sessionId, token: token) - default: - throw MCPError.methodNotFound(name) - } - } - - private func logToolOutcome( - name: String, - token: MCPAuthToken?, - arguments: JSONValue?, - outcome: AuditOutcome, - error: String? - ) { - let connectionId = arguments?["connection_id"]?.stringValue.flatMap(UUID.init(uuidString:)) - MCPAuditLogger.logToolCalled( - tokenId: token?.id, - tokenName: token?.name, - toolName: name, - connectionId: connectionId, - outcome: outcome, - errorMessage: error - ) - } - - private func authorize( - token: MCPAuthToken?, - tool: String, - connectionId: UUID?, - sql: String? = nil, - sessionId: String - ) async throws { - try await authPolicy.resolveAndAuthorize( - token: token ?? Self.anonymousFullAccessToken, - tool: tool, - connectionId: connectionId, - sql: sql, - sessionId: sessionId - ) - } - - static let anonymousFullAccessToken = MCPAuthToken( - id: UUID(), - name: "__anonymous__", - prefix: "tp_anon", - tokenHash: "", - salt: "", - permissions: .fullAccess, - connectionAccess: .all, - createdAt: Date.now, - lastUsedAt: nil, - expiresAt: nil, - isActive: true - ) - - private func handleListConnections(token: MCPAuthToken?) async throws -> MCPToolResult { - let result = await bridge.listConnections() - let filtered = filterConnectionsByToken(result, token: token) - return MCPToolResult(content: [.text(encodeJSON(filtered))], isError: nil) - } - - private func filterConnectionsByToken(_ value: JSONValue, token: MCPAuthToken?) -> JSONValue { - guard let access = token?.connectionAccess, case .limited(let allowed) = access else { - return value - } - guard case .object(var dict) = value, - let entries = dict["connections"]?.arrayValue - else { - return value - } - let filtered = entries.filter { entry in - guard let idString = entry["id"]?.stringValue, - let id = UUID(uuidString: idString) - else { - return false - } - return allowed.contains(id) - } - dict["connections"] = .array(filtered) - return .object(dict) - } - - private func handleConnect(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - try await authorize(token: token, tool: "connect", connectionId: connectionId, sessionId: sessionId) - let result = try await bridge.connect(connectionId: connectionId) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func handleDisconnect(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - try await authorize(token: token, tool: "disconnect", connectionId: connectionId, sessionId: sessionId) - try await bridge.disconnect(connectionId: connectionId) - let result: JSONValue = .object(["status": "disconnected"]) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func handleGetConnectionStatus(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - try await authorize(token: token, tool: "get_connection_status", connectionId: connectionId, sessionId: sessionId) - let result = try await bridge.getConnectionStatus(connectionId: connectionId) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func handleExecuteQuery(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - let query = try requireString(args, key: "query") - let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp } - let maxRows = optionalInt(args, key: "max_rows", default: mcpSettings.defaultRowLimit, clamp: 1...mcpSettings.maxRowLimit) - let timeoutSeconds = optionalInt(args, key: "timeout_seconds", default: mcpSettings.queryTimeoutSeconds, clamp: 1...300) - let database = optionalString(args, key: "database") - let schema = optionalString(args, key: "schema") - - guard (query as NSString).length <= 102_400 else { - throw MCPError.invalidParams("Query exceeds 100KB limit") - } - - guard !QueryClassifier.isMultiStatement(query) else { - throw MCPError.invalidParams("Multi-statement queries are not supported. Send one statement at a time.") - } - - try await authorize( - token: token, - tool: "execute_query", - connectionId: connectionId, - sql: query, - sessionId: sessionId - ) - - let (databaseType, safeModeLevel, databaseName) = try await resolveConnectionMeta(connectionId) - - if let database { - _ = try await bridge.switchDatabase(connectionId: connectionId, database: database) - } - if let schema { - _ = try await bridge.switchSchema(connectionId: connectionId, schema: schema) - } - - let tier = QueryClassifier.classifyTier(query, databaseType: databaseType) - - switch tier { - case .destructive: - throw MCPError.forbidden( - "Destructive queries (DROP, TRUNCATE, ALTER...DROP) cannot be executed via execute_query. " - + "Use the confirm_destructive_operation tool instead." - ) - - case .write: - if let token, !token.permissions.satisfies(.readWrite) { - throw MCPError.forbidden( - "Token '\(token.name)' with '\(token.permissions.displayName)' permission cannot execute write queries" - ) - } - try await authPolicy.checkSafeModeDialog( - sql: query, - connectionId: connectionId, - databaseType: databaseType, - safeModeLevel: safeModeLevel - ) - - case .safe: - try await authPolicy.checkSafeModeDialog( - sql: query, - connectionId: connectionId, - databaseType: databaseType, - safeModeLevel: safeModeLevel - ) - } - - let result = try await executeAndLog( - query: query, - connectionId: connectionId, - databaseName: databaseName, - maxRows: maxRows, - timeoutSeconds: timeoutSeconds, - token: token - ) - - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func handleConfirmDestructiveOperation( - _ args: JSONValue?, - sessionId: String, - token: MCPAuthToken? - ) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - let query = try requireString(args, key: "query") - let confirmationPhrase = try requireString(args, key: "confirmation_phrase") - - guard confirmationPhrase == "I understand this is irreversible" else { - throw MCPError.invalidParams( - "confirmation_phrase must be exactly: I understand this is irreversible" - ) - } - - guard !QueryClassifier.isMultiStatement(query) else { - throw MCPError.invalidParams( - "Multi-statement queries are not supported. Send one statement at a time." - ) - } - - try await authorize( - token: token, - tool: "confirm_destructive_operation", - connectionId: connectionId, - sql: query, - sessionId: sessionId - ) - - let (databaseType, safeModeLevel, databaseName) = try await resolveConnectionMeta(connectionId) - - let tier = QueryClassifier.classifyTier(query, databaseType: databaseType) - guard tier == .destructive else { - throw MCPError.invalidParams( - "This tool only accepts destructive queries (DROP, TRUNCATE, ALTER...DROP). " - + "Use execute_query for other queries." - ) - } - - try await authPolicy.checkSafeModeDialog( - sql: query, - connectionId: connectionId, - databaseType: databaseType, - safeModeLevel: safeModeLevel - ) - - let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp } - let timeoutSeconds = mcpSettings.queryTimeoutSeconds - - let result = try await executeAndLog( - query: query, - connectionId: connectionId, - databaseName: databaseName, - maxRows: 0, - timeoutSeconds: timeoutSeconds, - token: token - ) - - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func handleListTables(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - let includeRowCounts = optionalBool(args, key: "include_row_counts", default: false) - let database = optionalString(args, key: "database") - let schema = optionalString(args, key: "schema") - - try await authorize(token: token, tool: "list_tables", connectionId: connectionId, sessionId: sessionId) - - if let database { - _ = try await bridge.switchDatabase(connectionId: connectionId, database: database) - } - if let schema { - _ = try await bridge.switchSchema(connectionId: connectionId, schema: schema) - } - - let result = try await bridge.listTables(connectionId: connectionId, includeRowCounts: includeRowCounts) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func handleDescribeTable(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - let table = try requireString(args, key: "table") - let schema = optionalString(args, key: "schema") - - try await authorize(token: token, tool: "describe_table", connectionId: connectionId, sessionId: sessionId) - - let result = try await bridge.describeTable(connectionId: connectionId, table: table, schema: schema) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func handleListDatabases(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - try await authorize(token: token, tool: "list_databases", connectionId: connectionId, sessionId: sessionId) - let result = try await bridge.listDatabases(connectionId: connectionId) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func handleListSchemas(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - let database = optionalString(args, key: "database") - - try await authorize(token: token, tool: "list_schemas", connectionId: connectionId, sessionId: sessionId) - - if let database { - _ = try await bridge.switchDatabase(connectionId: connectionId, database: database) - } - - let result = try await bridge.listSchemas(connectionId: connectionId) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func handleGetTableDDL(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - let table = try requireString(args, key: "table") - let schema = optionalString(args, key: "schema") - - try await authorize(token: token, tool: "get_table_ddl", connectionId: connectionId, sessionId: sessionId) - - let result = try await bridge.getTableDDL(connectionId: connectionId, table: table, schema: schema) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func handleExportData(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - let format = try requireString(args, key: "format") - let query = optionalString(args, key: "query") - let tables = optionalStringArray(args, key: "tables") - let outputPath = optionalString(args, key: "output_path") - let maxRows = optionalInt(args, key: "max_rows", default: 50_000, clamp: 1...100_000) - - guard ["csv", "json", "sql"].contains(format) else { - throw MCPError.invalidParams("Unsupported format: \(format). Must be csv, json, or sql") - } - - guard query != nil || tables != nil else { - throw MCPError.invalidParams("Either 'query' or 'tables' must be provided") - } - - if let tables { - for table in tables { - try Self.validateExportTableName(table) - } - } - - if let outputPath { - _ = try Self.sandboxedDownloadsURL(for: outputPath) - } - - try await authorize( - token: token, - tool: "export_data", - connectionId: connectionId, - sql: query, - sessionId: sessionId - ) - - let (databaseType, safeModeLevel, _) = try await resolveConnectionMeta(connectionId) - var queries: [(label: String, sql: String)] = [] - - if let query { - try await authPolicy.checkSafeModeDialog( - sql: query, - connectionId: connectionId, - databaseType: databaseType, - safeModeLevel: safeModeLevel - ) - queries.append((label: "query", sql: query)) - } else if let tables { - let quoteIdentifier = Self.identifierQuoter(for: databaseType) - for table in tables { - let quoted = try Self.quoteQualifiedIdentifier(table, quoter: quoteIdentifier) - let sql = "SELECT * FROM \(quoted) LIMIT \(maxRows)" - try await authPolicy.checkSafeModeDialog( - sql: sql, - connectionId: connectionId, - databaseType: databaseType, - safeModeLevel: safeModeLevel - ) - queries.append((label: table, sql: sql)) - } - } - - var exportResults: [JSONValue] = [] - var totalRowsExported = 0 - - for (label, sql) in queries { - let result = try await bridge.executeQuery( - connectionId: connectionId, - query: sql, - maxRows: maxRows, - timeoutSeconds: 60 - ) - - guard let columns = result["columns"]?.arrayValue, - let rows = result["rows"]?.arrayValue - else { - throw MCPError.internalError("Unexpected query result structure") - } - - let columnNames = columns.compactMap(\.stringValue) - let formatted: String - - switch format { - case "csv": - formatted = formatCSV(columns: columnNames, rows: rows) - case "json": - formatted = formatJSON(columns: columnNames, rows: rows) - case "sql": - formatted = formatSQL(table: label, columns: columnNames, rows: rows) - default: - formatted = formatCSV(columns: columnNames, rows: rows) - } - - totalRowsExported += rows.count - - exportResults.append(.object([ - "label": .string(label), - "format": .string(format), - "row_count": result["row_count"] ?? .int(0), - "data": .string(formatted) - ])) - } - - if let outputPath { - let fileURL = try Self.sandboxedDownloadsURL(for: outputPath) - - let fullContent: String - if exportResults.count == 1, - let data = exportResults.first?["data"]?.stringValue - { - fullContent = data - } else { - fullContent = exportResults.compactMap { $0["data"]?.stringValue }.joined(separator: "\n\n") - } - - try fullContent.write(to: fileURL, atomically: true, encoding: .utf8) - - let response: JSONValue = .object([ - "path": .string(fileURL.path), - "rows_exported": .int(totalRowsExported) - ]) - return MCPToolResult(content: [.text(encodeJSON(response))], isError: nil) - } - - let response: JSONValue - if exportResults.count == 1, let single = exportResults.first { - response = single - } else { - response = .object(["exports": .array(exportResults)]) - } - - return MCPToolResult(content: [.text(encodeJSON(response))], isError: nil) - } - - private func handleSwitchDatabase(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - let database = try requireString(args, key: "database") - - try await authorize(token: token, tool: "switch_database", connectionId: connectionId, sessionId: sessionId) - - let result = try await bridge.switchDatabase(connectionId: connectionId, database: database) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func handleSwitchSchema(_ args: JSONValue?, sessionId: String, token: MCPAuthToken?) async throws -> MCPToolResult { - let connectionId = try requireUUID(args, key: "connection_id") - let schema = try requireString(args, key: "schema") - - try await authorize(token: token, tool: "switch_schema", connectionId: connectionId, sessionId: sessionId) - - let result = try await bridge.switchSchema(connectionId: connectionId, schema: schema) - return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) - } - - private func executeAndLog( - query: String, - connectionId: UUID, - databaseName: String, - maxRows: Int, - timeoutSeconds: Int, - token: MCPAuthToken? = nil - ) async throws -> JSONValue { - let startTime = Date() - do { - let result = try await bridge.executeQuery( - connectionId: connectionId, - query: query, - maxRows: maxRows, - timeoutSeconds: timeoutSeconds - ) - let elapsed = Date().timeIntervalSince(startTime) - let rowCount = result["row_count"]?.intValue ?? 0 - await authPolicy.logQuery( - sql: query, - connectionId: connectionId, - databaseName: databaseName, - executionTime: elapsed, - rowCount: rowCount, - wasSuccessful: true, - errorMessage: nil - ) - MCPAuditLogger.logQueryExecuted( - tokenId: token?.id, - tokenName: token?.name, - connectionId: connectionId, - sql: query, - durationMs: Int(elapsed * 1_000), - rowCount: rowCount, - outcome: .success - ) - return result - } catch { - let elapsed = Date().timeIntervalSince(startTime) - await authPolicy.logQuery( - sql: query, - connectionId: connectionId, - databaseName: databaseName, - executionTime: elapsed, - rowCount: 0, - wasSuccessful: false, - errorMessage: error.localizedDescription - ) - MCPAuditLogger.logQueryExecuted( - tokenId: token?.id, - tokenName: token?.name, - connectionId: connectionId, - sql: query, - durationMs: Int(elapsed * 1_000), - rowCount: 0, - outcome: .error, - errorMessage: error.localizedDescription - ) - throw error - } - } - - func requireUUID(_ args: JSONValue?, key: String) throws -> UUID { - guard let value = args?[key]?.stringValue else { - throw MCPError.invalidParams("Missing required parameter: \(key)") - } - guard let uuid = UUID(uuidString: value) else { - throw MCPError.invalidParams("Invalid UUID for parameter: \(key)") - } - return uuid - } - - func requireString(_ args: JSONValue?, key: String) throws -> String { - guard let value = args?[key]?.stringValue else { - throw MCPError.invalidParams("Missing required parameter: \(key)") - } - return value - } - - func optionalString(_ args: JSONValue?, key: String) -> String? { - args?[key]?.stringValue - } - - func optionalInt(_ args: JSONValue?, key: String, default defaultValue: Int, clamp range: ClosedRange) -> Int { - guard let value = args?[key]?.intValue else { return defaultValue } - return min(max(value, range.lowerBound), range.upperBound) - } - - private func optionalBool(_ args: JSONValue?, key: String, default defaultValue: Bool) -> Bool { - args?[key]?.boolValue ?? defaultValue - } - - private func optionalStringArray(_ args: JSONValue?, key: String) -> [String]? { - guard let array = args?[key]?.arrayValue else { return nil } - let strings = array.compactMap(\.stringValue) - return strings.isEmpty ? nil : strings - } - - private func resolveConnectionMeta(_ connectionId: UUID) async throws -> (DatabaseType, SafeModeLevel, String) { - try await MainActor.run { - switch DatabaseManager.shared.connectionState(connectionId) { - case .live(_, let session): - return (session.connection.type, session.connection.safeModeLevel, session.activeDatabase) - case .stored(let conn): - return (conn.type, conn.safeModeLevel, conn.database) - case .unknown: - throw MCPError.notConnected(connectionId) - } - } - } - - static func validateExportTableName(_ table: String) throws { - let pattern = "^[A-Za-z0-9_]+(\\.[A-Za-z0-9_]+)*$" - guard table.range(of: pattern, options: .regularExpression) != nil else { - throw MCPError.invalidParams( - "Invalid table name: '\(table)'. Allowed characters: letters, digits, underscore, and '.' for schema-qualified names." - ) - } - } - - static func identifierQuoter(for databaseType: DatabaseType) -> (String) -> String { - if let dialect = try? resolveSQLDialect(for: databaseType) { - return quoteIdentifierFromDialect(dialect) - } - return { "\"\($0.replacingOccurrences(of: "\"", with: "\"\""))\"" } - } - - static func quoteQualifiedIdentifier(_ identifier: String, quoter: (String) -> String) throws -> String { - let segments = identifier.split(separator: ".", omittingEmptySubsequences: true) - guard !segments.isEmpty, segments.count == identifier.split(separator: ".", omittingEmptySubsequences: false).count else { - throw MCPError.invalidParams( - "Invalid qualified identifier: '\(identifier)'. Empty components are not allowed." - ) - } - return segments.map { quoter(String($0)) }.joined(separator: ".") - } - - static func sandboxedDownloadsURL(for path: String) throws -> URL { - guard let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first else { - throw MCPError.invalidParams("Downloads directory is not available") - } - let downloadsRoot = downloads.standardizedFileURL.resolvingSymlinksInPath().path - let candidate = path.hasPrefix("/") ? URL(fileURLWithPath: path) : downloads.appendingPathComponent(path) - let resolvedPath = candidate.standardizedFileURL.resolvingSymlinksInPath().path - let prefix = downloadsRoot.hasSuffix("/") ? downloadsRoot : downloadsRoot + "/" - guard resolvedPath == downloadsRoot || resolvedPath.hasPrefix(prefix) else { - throw MCPError.invalidParams( - "output_path must be inside the Downloads directory (\(downloadsRoot))" - ) - } - return URL(fileURLWithPath: resolvedPath) - } - - func encodeJSON(_ value: JSONValue) -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - guard let data = try? encoder.encode(value), - let string = String(data: data, encoding: .utf8) - else { - Self.logger.warning("Failed to encode JSON value") - return "{}" - } - return string - } - - private func formatCSV(columns: [String], rows: [JSONValue]) -> String { - var lines: [String] = [] - lines.append(columns.map { escapeCSVField($0) }.joined(separator: ",")) - - for row in rows { - guard let cells = row.arrayValue else { continue } - let line = cells.map { cell -> String in - switch cell { - case .string(let value): - return escapeCSVField(value) - case .null: - return "" - case .int(let value): - return String(value) - case .double(let value): - return String(value) - case .bool(let value): - return value ? "true" : "false" - default: - return escapeCSVField(encodeJSON(cell)) - } - } - lines.append(line.joined(separator: ",")) - } - - return lines.joined(separator: "\n") - } - - private func escapeCSVField(_ field: String) -> String { - if field.contains(",") || field.contains("\"") || field.contains("\n") { - return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\"" - } - return field - } - - private func formatJSON(columns: [String], rows: [JSONValue]) -> String { - var objects: [JSONValue] = [] - - for row in rows { - guard let cells = row.arrayValue else { continue } - var dict: [String: JSONValue] = [:] - for (index, column) in columns.enumerated() where index < cells.count { - dict[column] = cells[index] - } - objects.append(.object(dict)) - } - - return encodeJSON(.array(objects)) - } - - private func formatSQL(table: String, columns: [String], rows: [JSONValue]) -> String { - guard !columns.isEmpty else { return "" } - - var statements: [String] = [] - let escapedTable = "`\(table.replacingOccurrences(of: "`", with: "``"))`" - let escapedColumns = columns.map { "`\($0.replacingOccurrences(of: "`", with: "``"))`" } - let columnList = escapedColumns.joined(separator: ", ") - - for row in rows { - guard let cells = row.arrayValue else { continue } - let values = cells.map { cell -> String in - switch cell { - case .null: - return "NULL" - case .string(let value): - let escaped = value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") - return "'\(escaped)'" - case .int(let value): - return String(value) - case .double(let value): - return String(value) - case .bool(let value): - return value ? "1" : "0" - default: - let escaped = encodeJSON(cell) - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") - return "'\(escaped)'" - } - } - statements.append("INSERT INTO \(escapedTable) (\(columnList)) VALUES (\(values.joined(separator: ", ")));") - } - - return statements.joined(separator: "\n") - } -} diff --git a/TablePro/Core/MCP/Protocol/Handlers/ResourcesListHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ResourcesListHandler.swift index 7425dde17..b2b42012f 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/ResourcesListHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/ResourcesListHandler.swift @@ -44,8 +44,7 @@ public struct ResourcesListHandler: MCPMethodHandler { } private static func connectedConnectionItems(services: MCPToolServices) async -> [ConnectedConnectionItem] { - let legacy = await services.connectionBridge.listConnections() - let value = JsonValue.fromLegacy(legacy) + let value = await services.connectionBridge.listConnections() guard let connections = value["connections"]?.arrayValue else { return [] } return connections.compactMap { entry -> ConnectedConnectionItem? in diff --git a/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift index 237a80ec8..bed4c315c 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift @@ -96,26 +96,23 @@ public struct ResourcesReadHandler: MCPMethodHandler { private static func fetchPayload(for route: ResourceRoute, services: MCPToolServices) async throws -> JsonValue { switch route { case .connectionsList: - let legacy = await services.connectionBridge.listConnections() - return JsonValue.fromLegacy(legacy) + return await services.connectionBridge.listConnections() case .connectionSchema(let connectionId): do { - let legacy = try await services.connectionBridge.fetchSchemaResource(connectionId: connectionId) - return JsonValue.fromLegacy(legacy) + return try await services.connectionBridge.fetchSchemaResource(connectionId: connectionId) } catch let error as MCPError { throw mapLegacyError(error) } case .connectionHistory(let connectionId, let limit, let search, let dateFilter): do { - let legacy = try await services.connectionBridge.fetchHistoryResource( + return try await services.connectionBridge.fetchHistoryResource( connectionId: connectionId, limit: limit, search: search, dateFilter: dateFilter ) - return JsonValue.fromLegacy(legacy) } catch let error as MCPError { throw mapLegacyError(error) } diff --git a/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift b/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift index bcff85c0b..5cf4d1742 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift @@ -86,6 +86,6 @@ public struct ConfirmDestructiveOperationTool: MCPToolImplementation { timeoutSeconds: timeoutSeconds ) - return .json(JsonValue.fromLegacy(result)) + return .json(result) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift b/TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift index 67de055e5..f2c4d4e5e 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift @@ -27,7 +27,7 @@ public struct ConnectTool: MCPToolImplementation { ) async throws -> MCPToolCallResult { let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") Self.logger.debug("connect tool invoked for connection \(connectionId.uuidString, privacy: .public)") - let legacy = try await services.connectionBridge.connect(connectionId: connectionId) - return .json(JsonValue.fromLegacy(legacy)) + let payload = try await services.connectionBridge.connect(connectionId: connectionId) + return .json(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift b/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift index 024b08165..90a80682f 100644 --- a/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift @@ -37,11 +37,11 @@ public struct DescribeTableTool: MCPToolImplementation { let table = try MCPArgumentDecoder.requireString(arguments, key: "table") let schema = MCPArgumentDecoder.optionalString(arguments, key: "schema") - let legacy = try await services.connectionBridge.describeTable( + let payload = try await services.connectionBridge.describeTable( connectionId: connectionId, table: table, schema: schema ) - return .json(JsonValue.fromLegacy(legacy)) + return .json(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift index 41fff8d58..d99f2a475 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift @@ -128,10 +128,8 @@ public struct ExecuteQueryTool: MCPToolImplementation { try await throwIfCancelled(context) await context.progress.emit(progress: 0.8, total: 1.0, message: "Formatting result") - let payload = JsonValue.fromLegacy(result) - await context.progress.emit(progress: 1.0, total: 1.0, message: "Done") - return .json(payload) + return .json(result) } private func classifyAndAuthorize( diff --git a/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift index 0a9b5907f..df31d8664 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift @@ -109,7 +109,7 @@ public struct ExportDataTool: MCPToolImplementation { } } - var exportResults: [JSONValue] = [] + var exportResults: [JsonValue] = [] var totalRowsExported = 0 for (label, sql) in queries { @@ -164,20 +164,20 @@ public struct ExportDataTool: MCPToolImplementation { } try fullContent.write(to: fileURL, atomically: true, encoding: .utf8) - let response: JSONValue = .object([ + let response: JsonValue = .object([ "path": .string(fileURL.path), "rows_exported": .int(totalRowsExported) ]) - return .json(JsonValue.fromLegacy(response)) + return .json(response) } - let response: JSONValue + let response: JsonValue if exportResults.count == 1, let single = exportResults.first { response = single } else { response = .object(["exports": .array(exportResults)]) } - return .json(JsonValue.fromLegacy(response)) + return .json(response) } static func validateExportTableName(_ table: String) throws { @@ -222,7 +222,7 @@ public struct ExportDataTool: MCPToolImplementation { return URL(fileURLWithPath: resolvedPath) } - static func formatCSV(columns: [String], rows: [JSONValue]) -> String { + static func formatCSV(columns: [String], rows: [JsonValue]) -> String { var lines: [String] = [] lines.append(columns.map { escapeCSVField($0) }.joined(separator: ",")) for row in rows { @@ -255,11 +255,11 @@ public struct ExportDataTool: MCPToolImplementation { return field } - static func formatJSON(columns: [String], rows: [JSONValue]) -> String { - var objects: [JSONValue] = [] + static func formatJSON(columns: [String], rows: [JsonValue]) -> String { + var objects: [JsonValue] = [] for row in rows { guard let cells = row.arrayValue else { continue } - var dict: [String: JSONValue] = [:] + var dict: [String: JsonValue] = [:] for (index, column) in columns.enumerated() where index < cells.count { dict[column] = cells[index] } @@ -268,7 +268,7 @@ public struct ExportDataTool: MCPToolImplementation { return encodeJSON(.array(objects)) } - static func formatSQL(table: String, columns: [String], rows: [JSONValue]) -> String { + static func formatSQL(table: String, columns: [String], rows: [JsonValue]) -> String { guard !columns.isEmpty else { return "" } var statements: [String] = [] let escapedTable = "`\(table.replacingOccurrences(of: "`", with: "``"))`" @@ -304,7 +304,7 @@ public struct ExportDataTool: MCPToolImplementation { return statements.joined(separator: "\n") } - static func encodeJSON(_ value: JSONValue) -> String { + static func encodeJSON(_ value: JsonValue) -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] guard let data = try? encoder.encode(value), diff --git a/TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift b/TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift index dbcc2af6c..1fedef48d 100644 --- a/TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift @@ -27,7 +27,7 @@ public struct FocusQueryTabTool: MCPToolImplementation { let tabId = try MCPArgumentDecoder.requireUuid(arguments, key: "tab_id") let resolved: (windowId: UUID?, connectionId: UUID, raised: Bool)? = await MainActor.run { - for snapshot in MCPToolHandler.collectTabSnapshots() where snapshot.tabId == tabId { + for snapshot in MCPTabSnapshotProvider.collectTabSnapshots() where snapshot.tabId == tabId { guard let window = snapshot.window else { return (windowId: snapshot.windowId, connectionId: snapshot.connectionId, raised: false) } diff --git a/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift b/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift index 0c67772ae..749dca074 100644 --- a/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift @@ -24,7 +24,7 @@ public struct GetConnectionStatusTool: MCPToolImplementation { services: MCPToolServices ) async throws -> MCPToolCallResult { let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") - let legacy = try await services.connectionBridge.getConnectionStatus(connectionId: connectionId) - return .json(JsonValue.fromLegacy(legacy)) + let payload = try await services.connectionBridge.getConnectionStatus(connectionId: connectionId) + return .json(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift b/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift index 5fde55248..886879e05 100644 --- a/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift @@ -35,11 +35,11 @@ public struct GetTableDdlTool: MCPToolImplementation { let table = try MCPArgumentDecoder.requireString(arguments, key: "table") let schema = MCPArgumentDecoder.optionalString(arguments, key: "schema") - let legacy = try await services.connectionBridge.getTableDDL( + let payload = try await services.connectionBridge.getTableDDL( connectionId: connectionId, table: table, schema: schema ) - return .json(JsonValue.fromLegacy(legacy)) + return .json(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/JsonValueLegacyBridge.swift b/TablePro/Core/MCP/Protocol/Tools/JsonValueLegacyBridge.swift deleted file mode 100644 index e8735ffac..000000000 --- a/TablePro/Core/MCP/Protocol/Tools/JsonValueLegacyBridge.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation - -enum JsonValueLegacyBridge { - static func toLegacy(_ value: JsonValue) -> JSONValue { - switch value { - case .null: - return .null - case .bool(let bool): - return .bool(bool) - case .int(let int): - return .int(int) - case .double(let double): - return .double(double) - case .string(let string): - return .string(string) - case .array(let array): - return .array(array.map { toLegacy($0) }) - case .object(let object): - return .object(object.mapValues { toLegacy($0) }) - } - } - - static func fromLegacy(_ value: JSONValue) -> JsonValue { - switch value { - case .null: - return .null - case .bool(let bool): - return .bool(bool) - case .int(let int): - return .int(int) - case .double(let double): - return .double(double) - case .string(let string): - return .string(string) - case .array(let array): - return .array(array.map { fromLegacy($0) }) - case .object(let object): - return .object(object.mapValues { fromLegacy($0) }) - } - } -} - -extension JsonValue { - func toLegacy() -> JSONValue { - JsonValueLegacyBridge.toLegacy(self) - } - - static func fromLegacy(_ value: JSONValue) -> JsonValue { - JsonValueLegacyBridge.fromLegacy(value) - } -} diff --git a/TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift index ba81be00b..21836675e 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift @@ -18,7 +18,7 @@ public struct ListConnectionsTool: MCPToolImplementation { context: MCPRequestContext, services: MCPToolServices ) async throws -> MCPToolCallResult { - let legacy = await services.connectionBridge.listConnections() - return .json(JsonValue.fromLegacy(legacy)) + let payload = await services.connectionBridge.listConnections() + return .json(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift index b20d10678..be4e433d7 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift @@ -24,7 +24,7 @@ public struct ListDatabasesTool: MCPToolImplementation { services: MCPToolServices ) async throws -> MCPToolCallResult { let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") - let legacy = try await services.connectionBridge.listDatabases(connectionId: connectionId) - return .json(JsonValue.fromLegacy(legacy)) + let payload = try await services.connectionBridge.listDatabases(connectionId: connectionId) + return .json(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift index 896b4e748..32e70f239 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift @@ -27,8 +27,8 @@ public struct ListRecentTabsTool: MCPToolImplementation { ) async throws -> MCPToolCallResult { let limit = MCPArgumentDecoder.optionalInt(arguments, key: "limit", default: 20, clamp: 1...500) ?? 20 - let snapshots = await MainActor.run { MCPToolHandler.collectTabSnapshots() } - let blocked = await MainActor.run { MCPToolHandler.blockedExternalConnectionIds() } + let snapshots = await MainActor.run { MCPTabSnapshotProvider.collectTabSnapshots() } + let blocked = await MainActor.run { MCPTabSnapshotProvider.blockedExternalConnectionIds() } let filtered = snapshots.filter { !blocked.contains($0.connectionId) } let trimmed = Array(filtered.prefix(limit)) diff --git a/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift index 9eba0d5f6..8484ad7de 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift @@ -34,7 +34,7 @@ public struct ListSchemasTool: MCPToolImplementation { _ = try await services.connectionBridge.switchDatabase(connectionId: connectionId, database: database) } - let legacy = try await services.connectionBridge.listSchemas(connectionId: connectionId) - return .json(JsonValue.fromLegacy(legacy)) + let payload = try await services.connectionBridge.listSchemas(connectionId: connectionId) + return .json(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift index 9a2c7f97d..e390331e8 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift @@ -47,10 +47,10 @@ public struct ListTablesTool: MCPToolImplementation { _ = try await services.connectionBridge.switchSchema(connectionId: connectionId, schema: schema) } - let legacy = try await services.connectionBridge.listTables( + let payload = try await services.connectionBridge.listTables( connectionId: connectionId, includeRowCounts: includeRowCounts ) - return .json(JsonValue.fromLegacy(legacy)) + return .json(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift new file mode 100644 index 000000000..ff88d480c --- /dev/null +++ b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift @@ -0,0 +1,66 @@ +import AppKit +import Foundation + +struct MCPTabSnapshot { + let tabId: UUID + let connectionId: UUID + let connectionName: String + let tabType: String + let tableName: String? + let databaseName: String? + let schemaName: String? + let displayTitle: String + let windowId: UUID? + let isActive: Bool + weak var window: NSWindow? +} + +enum MCPTabSnapshotProvider { + @MainActor + static func collectTabSnapshots() -> [MCPTabSnapshot] { + let connections = ConnectionStorage.shared.loadConnections() + let connectionsById = Dictionary(uniqueKeysWithValues: connections.map { ($0.id, $0) }) + + var snapshots: [MCPTabSnapshot] = [] + for coordinator in MainContentCoordinator.allActiveCoordinators() { + let connectionName = connectionsById[coordinator.connectionId]?.name + ?? coordinator.connection.name + let selectedId = coordinator.tabManager.selectedTabId + for tab in coordinator.tabManager.tabs { + snapshots.append(MCPTabSnapshot( + tabId: tab.id, + connectionId: coordinator.connectionId, + connectionName: connectionName, + tabType: tab.tabType.snapshotName, + tableName: tab.tableContext.tableName, + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName, + displayTitle: tab.title, + windowId: coordinator.windowId, + isActive: tab.id == selectedId, + window: coordinator.contentWindow + )) + } + } + return snapshots + } + + @MainActor + static func blockedExternalConnectionIds() -> Set { + let connections = ConnectionStorage.shared.loadConnections() + return Set(connections.filter { $0.externalAccess == .blocked }.map(\.id)) + } +} + +private extension TabType { + var snapshotName: String { + switch self { + case .query: "query" + case .table: "table" + case .createTable: "createTable" + case .erDiagram: "erDiagram" + case .serverDashboard: "serverDashboard" + case .terminal: "terminal" + } + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift b/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift index 83cc150c0..79a28f18d 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift @@ -51,7 +51,7 @@ public struct SearchQueryHistoryTool: MCPToolImplementation { throw MCPProtocolError.invalidParams(detail: "'since' must be less than or equal to 'until'") } - let blocked = await MainActor.run { MCPToolHandler.blockedExternalConnectionIds() } + let blocked = await MainActor.run { MCPTabSnapshotProvider.blockedExternalConnectionIds() } if let connectionId, blocked.contains(connectionId) { throw MCPProtocolError.forbidden(reason: "External access is disabled for this connection") diff --git a/TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift b/TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift index d81b45599..45c8d99fc 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift @@ -32,10 +32,10 @@ public struct SwitchDatabaseTool: MCPToolImplementation { let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") let database = try MCPArgumentDecoder.requireString(arguments, key: "database") Self.logger.debug("switch_database tool invoked for connection \(connectionId.uuidString, privacy: .public)") - let legacy = try await services.connectionBridge.switchDatabase( + let payload = try await services.connectionBridge.switchDatabase( connectionId: connectionId, database: database ) - return .json(JsonValue.fromLegacy(legacy)) + return .json(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift b/TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift index a4b529888..bd07d16f0 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift @@ -32,10 +32,10 @@ public struct SwitchSchemaTool: MCPToolImplementation { let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") let schema = try MCPArgumentDecoder.requireString(arguments, key: "schema") Self.logger.debug("switch_schema tool invoked for connection \(connectionId.uuidString, privacy: .public)") - let legacy = try await services.connectionBridge.switchSchema( + let payload = try await services.connectionBridge.switchSchema( connectionId: connectionId, schema: schema ) - return .json(JsonValue.fromLegacy(legacy)) + return .json(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift index 30f853b19..e0f4a0309 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift @@ -8,7 +8,7 @@ enum ToolQueryExecutor { databaseName: String, maxRows: Int, timeoutSeconds: Int - ) async throws -> JSONValue { + ) async throws -> JsonValue { let startTime = Date() do { let result = try await services.connectionBridge.executeQuery( diff --git a/TablePro/Core/MCP/Routes/IntegrationsExchangeHandler.swift b/TablePro/Core/MCP/Routes/IntegrationsExchangeHandler.swift deleted file mode 100644 index 794494a02..000000000 --- a/TablePro/Core/MCP/Routes/IntegrationsExchangeHandler.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import os - -struct IntegrationsExchangeHandler: MCPRouteHandler { - private static let logger = Logger(subsystem: "com.TablePro", category: "IntegrationsExchangeHandler") - - private let exchange: @Sendable (PairingExchange) async throws -> String - - private let encoder: JSONEncoder - private let decoder: JSONDecoder - - var methods: [HTTPRequest.Method] { [.post] } - var path: String { "/v1/integrations/exchange" } - - init(exchange: @escaping @Sendable (PairingExchange) async throws -> String) { - self.exchange = exchange - let enc = JSONEncoder() - enc.outputFormatting = [.sortedKeys] - self.encoder = enc - self.decoder = JSONDecoder() - } - - static func live() -> IntegrationsExchangeHandler { - IntegrationsExchangeHandler { request in - try await MainActor.run { - try MCPPairingService.shared.exchange(request) - } - } - } - - func handle(_ request: HTTPRequest) async -> MCPRouter.RouteResult { - guard let body = request.body else { - return .httpError(status: 400, message: "Missing request body") - } - - let parsed: ExchangeRequestBody - do { - parsed = try decoder.decode(ExchangeRequestBody.self, from: body) - } catch { - return .httpError(status: 400, message: "Invalid JSON body") - } - - guard !parsed.code.isEmpty, !parsed.codeVerifier.isEmpty else { - return .httpError(status: 400, message: "Missing code or code_verifier") - } - - let token: String - do { - token = try await exchange( - PairingExchange(code: parsed.code, verifier: parsed.codeVerifier) - ) - } catch let mcpError as MCPError { - return Self.mapExchangeError(mcpError) - } catch { - Self.logger.error("Pairing exchange failed: \(error.localizedDescription)") - return .httpError(status: 500, message: "Internal error") - } - - do { - let data = try encoder.encode(ExchangeResponseBody(token: token)) - return .json(data, sessionId: nil) - } catch { - Self.logger.error("Failed to encode exchange response: \(error.localizedDescription)") - return .httpError(status: 500, message: "Internal error") - } - } - - private static func mapExchangeError(_ error: MCPError) -> MCPRouter.RouteResult { - switch error { - case .notFound: - return .httpError(status: 404, message: "Pairing code not found") - case .expired: - return .httpError(status: 410, message: "Pairing code expired") - case .forbidden: - return .httpError(status: 403, message: "Challenge mismatch") - default: - return .httpError(status: 500, message: "Internal error") - } - } - - private struct ExchangeRequestBody: Decodable { - let code: String - let codeVerifier: String - - enum CodingKeys: String, CodingKey { - case code - case codeVerifier = "code_verifier" - } - } - - private struct ExchangeResponseBody: Encodable { - let token: String - } -} diff --git a/TablePro/Core/MCP/Routes/MCPProtocolHandler.swift b/TablePro/Core/MCP/Routes/MCPProtocolHandler.swift deleted file mode 100644 index 054b0fed9..000000000 --- a/TablePro/Core/MCP/Routes/MCPProtocolHandler.swift +++ /dev/null @@ -1,533 +0,0 @@ -import Foundation -import os - -final class MCPProtocolHandler: MCPRouteHandler, @unchecked Sendable { - private static let logger = Logger(subsystem: "com.TablePro", category: "MCPProtocolHandler") - - private weak var server: MCPServer? - private let tokenStore: MCPTokenStore? - private let rateLimiter: LegacyMCPRateLimiter? - - private let encoder: JSONEncoder - private let decoder: JSONDecoder - - var methods: [HTTPRequest.Method] { [.get, .post, .delete] } - var path: String { "/mcp" } - - init(server: MCPServer, tokenStore: MCPTokenStore?, rateLimiter: LegacyMCPRateLimiter?) { - self.server = server - self.tokenStore = tokenStore - self.rateLimiter = rateLimiter - let enc = JSONEncoder() - enc.outputFormatting = [.sortedKeys] - self.encoder = enc - self.decoder = JSONDecoder() - } - - func handle(_ request: HTTPRequest) async -> MCPRouter.RouteResult { - guard let server else { - return .httpError(status: 503, message: "Server unavailable") - } - - if let rateLimiter, let ip = request.remoteIP { - let lockoutCheck = await rateLimiter.isLockedOut(ip: ip) - if case .rateLimited(let retryAfter) = lockoutCheck { - let seconds = Int(retryAfter.components.seconds) - MCPAuditLogger.logRateLimited(ip: ip, retryAfterSeconds: seconds) - return .httpErrorWithHeaders( - status: 429, - message: "Too many failed attempts", - extraHeaders: [("Retry-After", "\(seconds)")] - ) - } - } - - let authResult = await authenticateRequest(request) - - switch authResult { - case .failure(let result): - return result - case .success(let token): - if token == nil { - if let origin = request.headers["origin"], !isAllowedOrigin(origin) { - return .httpError(status: 403, message: "Forbidden origin") - } - } - - switch request.method { - case .post: - return await handlePost(request, server: server, authenticatedToken: token) - case .get: - return await handleGet(request, server: server) - case .delete: - return await handleDelete(request, server: server) - case .options: - return .noContent - } - } - } - - private enum AuthResult { - case success(MCPAuthToken?) - case failure(MCPRouter.RouteResult) - } - - private func authenticateRequest(_ request: HTTPRequest) async -> AuthResult { - let remoteIP = request.remoteIP - let authRequired = await MainActor.run { AppSettingsManager.shared.mcp.requireAuthentication } - - guard let authHeader = request.headers["authorization"] else { - guard !authRequired else { - MCPAuditLogger.logAuthFailure(reason: "Missing authorization header", ip: remoteIP ?? "localhost") - return .failure(.httpErrorWithHeaders( - status: 401, - message: "Authentication required", - extraHeaders: [("WWW-Authenticate", "Bearer realm=\"TablePro MCP\"")] - )) - } - return .success(nil) - } - - guard authHeader.lowercased().hasPrefix("bearer "), let tokenStore else { - let rateLimitResult = await recordAuthFailure(ip: remoteIP) - if case .rateLimited(let retryAfter) = rateLimitResult { - let seconds = Int(retryAfter.components.seconds) - MCPAuditLogger.logRateLimited(ip: remoteIP ?? "localhost", retryAfterSeconds: seconds) - return .failure(.httpErrorWithHeaders( - status: 429, - message: "Too many failed attempts", - extraHeaders: [("Retry-After", "\(seconds)")] - )) - } - MCPAuditLogger.logAuthFailure(reason: "Invalid authorization header format", ip: remoteIP ?? "localhost") - return .failure(.httpErrorWithHeaders( - status: 401, - message: "Invalid authorization header", - extraHeaders: [("WWW-Authenticate", "Bearer realm=\"TablePro MCP\"")] - )) - } - - let bearerToken = String(authHeader.dropFirst(7)) - - guard let token = await tokenStore.validate(bearerToken: bearerToken) else { - let rateLimitResult = await recordAuthFailure(ip: remoteIP) - if case .rateLimited(let retryAfter) = rateLimitResult { - let seconds = Int(retryAfter.components.seconds) - MCPAuditLogger.logRateLimited(ip: remoteIP ?? "localhost", retryAfterSeconds: seconds) - return .failure(.httpErrorWithHeaders( - status: 429, - message: "Too many failed attempts", - extraHeaders: [("Retry-After", "\(seconds)")] - )) - } - MCPAuditLogger.logAuthFailure(reason: "Invalid token", ip: remoteIP ?? "localhost") - return .failure(.httpErrorWithHeaders( - status: 401, - message: "Invalid or expired token", - extraHeaders: [("WWW-Authenticate", "Bearer realm=\"TablePro MCP\"")] - )) - } - - if let rateLimiter, let ip = remoteIP { - _ = await rateLimiter.checkAndRecord(ip: ip, success: true) - } - MCPAuditLogger.logAuthSuccess(tokenName: token.name, ip: remoteIP ?? "localhost") - return .success(token) - } - - @discardableResult - private func recordAuthFailure(ip: String?) async -> LegacyMCPRateLimiter.AuthRateResult? { - guard let rateLimiter, let ip else { return nil } - return await rateLimiter.checkAndRecord(ip: ip, success: false) - } - - private func isAllowedOrigin(_ origin: String) -> Bool { - guard let components = URLComponents(string: origin), - let host = components.host - else { - return false - } - let allowedHosts: Set = ["localhost", "127.0.0.1", "::1"] - return allowedHosts.contains(host) - } - - private func handleGet(_ request: HTTPRequest, server: MCPServer) async -> MCPRouter.RouteResult { - guard let sessionId = request.headers["mcp-session-id"] else { - return .httpError(status: 400, message: "Missing Mcp-Session-Id header") - } - - guard let session = await server.session(for: sessionId) else { - return .httpError(status: 404, message: "Session not found") - } - - await session.markActive() - return .sseStream(sessionId: session.id) - } - - private func handleDelete(_ request: HTTPRequest, server: MCPServer) async -> MCPRouter.RouteResult { - guard let sessionId = request.headers["mcp-session-id"] else { - return .httpError(status: 400, message: "Missing Mcp-Session-Id header") - } - - guard await server.session(for: sessionId) != nil else { - return .httpError(status: 404, message: "Session not found") - } - - await server.removeSession(sessionId) - Self.logger.info("Session terminated via DELETE: \(sessionId)") - return .noContent - } - - private func handlePost( - _ request: HTTPRequest, - server: MCPServer, - authenticatedToken: MCPAuthToken? - ) async -> MCPRouter.RouteResult { - if let accept = request.headers["accept"], !accept.contains("application/json") && !accept.contains("*/*") { - return .httpError(status: 406, message: "Accept header must include application/json") - } - - guard let body = request.body else { - return encodeError(MCPError.parseError, id: nil) - } - - let rpcRequest: JSONRPCRequest - do { - rpcRequest = try decoder.decode(JSONRPCRequest.self, from: body) - } catch { - return encodeError(MCPError.parseError, id: nil) - } - - guard rpcRequest.jsonrpc == "2.0" else { - return encodeError(MCPError.invalidRequest("jsonrpc must be \"2.0\""), id: rpcRequest.id) - } - - if let protocolVersion = request.headers["mcp-protocol-version"], - protocolVersion != "2025-03-26" - { - Self.logger.warning("Client mcp-protocol-version mismatch: \(protocolVersion)") - } - - let headerSessionId = request.headers["mcp-session-id"] - return await dispatchMethod( - rpcRequest, - headerSessionId: headerSessionId, - server: server, - authenticatedToken: authenticatedToken - ) - } - - private func dispatchMethod( - _ request: JSONRPCRequest, - headerSessionId: String?, - server: MCPServer, - authenticatedToken: MCPAuthToken? - ) async -> MCPRouter.RouteResult { - if request.method == "initialize" { - return await handleInitialize(request, server: server) - } - - if request.method == "ping" { - return handlePing(request) - } - - guard let sessionId = headerSessionId else { - return .httpError(status: 400, message: "Missing Mcp-Session-Id header") - } - guard let session = await server.session(for: sessionId) else { - return .httpError(status: 404, message: "Session not found") - } - - await session.markActive() - - if request.method == "notifications/initialized" { - do { - try await session.transition(to: .active( - tokenId: authenticatedToken?.id, - tokenName: authenticatedToken?.name - )) - } catch { - return encodeError(MCPError.invalidRequest("Cannot initialize session in current phase"), id: request.id) - } - return .accepted - } - - if request.method == "notifications/cancelled" { - return await handleCancellation(request, session: session) - } - - guard await session.phase.isActive else { - return encodeError( - MCPError.invalidRequest("Session not initialized. Send notifications/initialized first."), - id: request.id - ) - } - - switch request.method { - case "tools/list": - return handleToolsList(request, sessionId: sessionId) - - case "tools/call": - return await handleToolsCall( - request, - sessionId: sessionId, - server: server, - authenticatedToken: authenticatedToken - ) - - case "resources/list": - return handleResourcesList(request, sessionId: sessionId) - - case "resources/read": - return await handleResourcesRead(request, sessionId: sessionId, server: server) - - default: - return encodeError(MCPError.methodNotFound(request.method), id: request.id) - } - } - - private func handleInitialize( - _ request: JSONRPCRequest, - server: MCPServer - ) async -> MCPRouter.RouteResult { - guard let session = await server.createSession() else { - return encodeError(MCPError.internalError("Maximum sessions reached"), id: request.id) - } - - if let params = request.params, - let clientInfo = params["clientInfo"], - let name = clientInfo["name"]?.stringValue - { - let version = clientInfo["version"]?.stringValue - await session.setClientInfo(LegacyMCPClientInfo(name: name, version: version)) - } - - do { - try await session.transition(to: .initializing) - } catch { - await server.removeSession(session.id) - return encodeError(MCPError.invalidRequest("Cannot initialize session"), id: request.id) - } - - let result = MCPInitializeResult( - protocolVersion: "2025-03-26", - capabilities: MCPServerCapabilities( - tools: .init(listChanged: false), - resources: .init(subscribe: false, listChanged: false) - ), - serverInfo: MCPServerInfo(name: "tablepro", version: "1.0.0") - ) - - return encodeResult(result, id: request.id, sessionId: session.id) - } - - private func handlePing(_ request: JSONRPCRequest) -> MCPRouter.RouteResult { - guard let id = request.id else { - return .accepted - } - return encodeRawResult(.object([:]), id: id, sessionId: nil) - } - - private func handleCancellation( - _ request: JSONRPCRequest, - session: LegacyMCPSession - ) async -> MCPRouter.RouteResult { - guard let params = request.params, - let requestIdValue = params["requestId"] - else { - return .accepted - } - - let cancelId: JSONRPCId? - switch requestIdValue { - case .string(let s): - cancelId = .string(s) - case .int(let i): - cancelId = .int(i) - default: - cancelId = nil - } - - if let cancelId, let task = await session.removeRunningTask(cancelId) { - task.cancel() - Self.logger.info("Cancelled request \(String(describing: cancelId)) in session \(session.id)") - } - - return .accepted - } - - private func handleToolsList(_ request: JSONRPCRequest, sessionId: String) -> MCPRouter.RouteResult { - guard let id = request.id else { - return .accepted - } - - let tools = MCPRouter.toolDefinitions() - let result: JSONValue = .object(["tools": encodeToolDefinitions(tools)]) - return encodeRawResult(result, id: id, sessionId: sessionId) - } - - private func handleToolsCall( - _ request: JSONRPCRequest, - sessionId: String, - server: MCPServer, - authenticatedToken: MCPAuthToken? - ) async -> MCPRouter.RouteResult { - guard let id = request.id else { - return encodeError(MCPError.invalidRequest("tools/call requires an id"), id: nil) - } - - guard let params = request.params, - let name = params["name"]?.stringValue - else { - return encodeError(MCPError.invalidParams("Missing tool name"), id: id) - } - - let arguments = params["arguments"] - - guard let handler = await server.toolCallHandler else { - return encodeError(MCPError.internalError("Server not fully initialized"), id: id) - } - - let session = await server.session(for: sessionId) - let toolTask = Task { - try await handler(name, arguments, sessionId, authenticatedToken) - } - if let session { - let cancelForwardingTask = Task { - await withTaskCancellationHandler { - _ = try? await toolTask.value - } onCancel: { - toolTask.cancel() - } - } - await session.addRunningTask(id, task: cancelForwardingTask) - } - - do { - let toolResult = try await toolTask.value - if let session { _ = await session.removeRunningTask(id) } - let resultData = try encoder.encode(toolResult) - guard let resultValue = try? decoder.decode(JSONValue.self, from: resultData) else { - return encodeError(MCPError.internalError("Failed to encode tool result"), id: id) - } - return encodeRawResult(resultValue, id: id, sessionId: sessionId) - } catch is CancellationError { - if let session { _ = await session.removeRunningTask(id) } - return encodeError(MCPError.timeout("Request was cancelled"), id: id) - } catch let mcpError as MCPError { - if let session { _ = await session.removeRunningTask(id) } - return encodeError(mcpError, id: id) - } catch { - if let session { _ = await session.removeRunningTask(id) } - return encodeError(MCPError.internalError(error.localizedDescription), id: id) - } - } - - private func handleResourcesList(_ request: JSONRPCRequest, sessionId: String) -> MCPRouter.RouteResult { - guard let id = request.id else { - return .accepted - } - - let resources = MCPRouter.resourceDefinitions() - let result: JSONValue = .object(["resources": encodeResourceDefinitions(resources)]) - return encodeRawResult(result, id: id, sessionId: sessionId) - } - - private func handleResourcesRead( - _ request: JSONRPCRequest, - sessionId: String, - server: MCPServer - ) async -> MCPRouter.RouteResult { - guard let id = request.id else { - return encodeError(MCPError.invalidRequest("resources/read requires an id"), id: nil) - } - - guard let params = request.params, - let uri = params["uri"]?.stringValue - else { - return encodeError(MCPError.invalidParams("Missing resource uri"), id: id) - } - - guard let handler = await server.resourceReadHandler else { - return encodeError(MCPError.internalError("Server not fully initialized"), id: id) - } - - do { - let readResult = try await handler(uri, sessionId) - let resultData = try encoder.encode(readResult) - guard let resultValue = try? decoder.decode(JSONValue.self, from: resultData) else { - return encodeError(MCPError.internalError("Failed to encode resource result"), id: id) - } - return encodeRawResult(resultValue, id: id, sessionId: sessionId) - } catch let mcpError as MCPError { - return encodeError(mcpError, id: id) - } catch { - return encodeError(MCPError.internalError(error.localizedDescription), id: id) - } - } - - private func encodeResult(_ result: T, id: JSONRPCId?, sessionId: String?) -> MCPRouter.RouteResult { - guard let id else { - return .accepted - } - - do { - let resultData = try encoder.encode(result) - let resultValue = try decoder.decode(JSONValue.self, from: resultData) - let response = JSONRPCResponse(id: id, result: resultValue) - let data = try encoder.encode(response) - return .json(data, sessionId: sessionId) - } catch { - Self.logger.error("Failed to encode response: \(error.localizedDescription)") - return encodeError(MCPError.internalError("Encoding failed"), id: id) - } - } - - private func encodeRawResult(_ result: JSONValue, id: JSONRPCId, sessionId: String?) -> MCPRouter.RouteResult { - do { - let response = JSONRPCResponse(id: id, result: result) - let data = try encoder.encode(response) - return .json(data, sessionId: sessionId) - } catch { - Self.logger.error("Failed to encode response: \(error.localizedDescription)") - return encodeError(MCPError.internalError("Encoding failed"), id: id) - } - } - - private func encodeError(_ error: MCPError, id: JSONRPCId?) -> MCPRouter.RouteResult { - let errorResponse = error.toJsonRpcError(id: id) - do { - let data = try encoder.encode(errorResponse) - return .json(data, sessionId: nil) - } catch { - Self.logger.error("Failed to encode error response") - return .httpError(status: 500, message: "Internal encoding error") - } - } - - private func encodeToolDefinitions(_ tools: [MCPToolDefinition]) -> JSONValue { - .array(tools.map { tool in - .object([ - "name": .string(tool.name), - "description": .string(tool.description), - "inputSchema": tool.inputSchema - ]) - }) - } - - private func encodeResourceDefinitions(_ resources: [MCPResourceDefinition]) -> JSONValue { - .array(resources.map { resource in - var dict: [String: JSONValue] = [ - "uri": .string(resource.uri), - "name": .string(resource.name) - ] - if let description = resource.description { - dict["description"] = .string(description) - } - if let mimeType = resource.mimeType { - dict["mimeType"] = .string(mimeType) - } - return .object(dict) - }) - } -} diff --git a/TablePro/Core/MCP/Session/MCPSession.swift b/TablePro/Core/MCP/Session/MCPSession.swift index 8b2f7b187..090a8105b 100644 --- a/TablePro/Core/MCP/Session/MCPSession.swift +++ b/TablePro/Core/MCP/Session/MCPSession.swift @@ -37,8 +37,8 @@ public enum MCPSessionTransitionError: Error, Sendable, Equatable { } public actor MCPSession { - public let id: MCPSessionId - public let createdAt: Date + nonisolated public let id: MCPSessionId + nonisolated public let createdAt: Date public private(set) var lastActivityAt: Date public private(set) var state: MCPSessionState public private(set) var clientInfo: MCPClientInfo? diff --git a/TablePro/Core/MCP/Session/MCPSessionStore.swift b/TablePro/Core/MCP/Session/MCPSessionStore.swift index 980f954b0..bc4bc1c71 100644 --- a/TablePro/Core/MCP/Session/MCPSessionStore.swift +++ b/TablePro/Core/MCP/Session/MCPSessionStore.swift @@ -58,6 +58,10 @@ public actor MCPSessionStore { sessions.count } + public func allSessions() async -> [MCPSession] { + Array(sessions.values) + } + public var events: AsyncStream { let (stream, continuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(64) diff --git a/TablePro/Views/Settings/Sections/MCPSection.swift b/TablePro/Views/Settings/Sections/MCPSection.swift index 644e59f0d..80bdce11b 100644 --- a/TablePro/Views/Settings/Sections/MCPSection.swift +++ b/TablePro/Views/Settings/Sections/MCPSection.swift @@ -10,7 +10,7 @@ struct MCPSection: View { @State private var showRevealSheet = false @State private var revealedToken: MCPAuthToken? @State private var revealedPlaintext: String = "" - @State private var disconnectCandidate: MCPServer.SessionSnapshot? + @State private var disconnectCandidate: MCPServerManager.SessionSnapshot? var body: some View { Section(String(localized: "Integrations")) { diff --git a/TableProTests/Core/MCP/LegacyMCPRateLimiterTests.swift b/TableProTests/Core/MCP/LegacyMCPRateLimiterTests.swift deleted file mode 100644 index 9d3916f3f..000000000 --- a/TableProTests/Core/MCP/LegacyMCPRateLimiterTests.swift +++ /dev/null @@ -1,216 +0,0 @@ -// -// MCPRateLimiterTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("MCP Rate Limiter") -struct MCPRateLimiterTests { - private func makeLimiter() -> LegacyMCPRateLimiter { - LegacyMCPRateLimiter() - } - - private func expectAllowed(_ result: LegacyMCPRateLimiter.AuthRateResult, message: String = "") { - guard case .allowed = result else { - Issue.record("Expected .allowed but got \(result). \(message)") - return - } - } - - @discardableResult - private func expectRateLimited(_ result: LegacyMCPRateLimiter.AuthRateResult, message: String = "") -> Duration? { - guard case .rateLimited(let retryAfter) = result else { - Issue.record("Expected .rateLimited but got \(result). \(message)") - return nil - } - return retryAfter - } - - @Test("First request is allowed") - func firstRequestAllowed() async { - let limiter = makeLimiter() - let result = await limiter.checkAndRecord(ip: "1.2.3.4", success: false) - expectAllowed(result) - } - - @Test("Success clears failure record") - func successClearsFailureRecord() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "1.2.3.4", success: false) - _ = await limiter.checkAndRecord(ip: "1.2.3.4", success: true) - let result = await limiter.checkAndRecord(ip: "1.2.3.4", success: false) - expectAllowed(result, message: "Counter should have been reset by success") - } - - @Test("Unknown IP is allowed") - func unknownIpAllowed() async { - let limiter = makeLimiter() - let result = await limiter.checkAndRecord(ip: "never-seen-before", success: false) - expectAllowed(result) - } - - @Test("isLockedOut for unknown IP returns allowed") - func isLockedOutUnknownIp() async { - let limiter = makeLimiter() - let result = await limiter.isLockedOut(ip: "unknown") - expectAllowed(result) - } - - @Test("Second failure triggers 1s lockout") - func secondFailureLockout() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.0.1", success: false) - let result = await limiter.checkAndRecord(ip: "10.0.0.1", success: false) - - guard let retryAfter = expectRateLimited(result, message: "Second failure should lock out") else { return } - let seconds = retryAfter.components.seconds - #expect(seconds >= 0 && seconds <= 2) - } - - @Test("Third failure triggers 5s lockout after previous lockout expires") - func thirdFailureLockout() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.0.2", success: false) - _ = await limiter.checkAndRecord(ip: "10.0.0.2", success: false) - - try? await Task.sleep(for: .seconds(1.1)) - - let result = await limiter.checkAndRecord(ip: "10.0.0.2", success: false) - guard let retryAfter = expectRateLimited(result, message: "Third failure should lock out for ~5s") else { return } - let seconds = retryAfter.components.seconds - #expect(seconds >= 4 && seconds <= 6) - } - - @Test("Fourth failure triggers 30s lockout") - func fourthFailureLockout() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) - _ = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) - - try? await Task.sleep(for: .seconds(1.1)) - _ = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) - - try? await Task.sleep(for: .seconds(5.1)) - let result = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) - - guard let retryAfter = expectRateLimited(result, message: "Fourth failure should lock out for ~30s") else { return } - let seconds = retryAfter.components.seconds - #expect(seconds >= 28 && seconds <= 32) - } - - @Test("Repeated failures while locked return remaining lockout time") - func repeatedFailuresWhileLocked() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.0.4", success: false) - let lockResult = await limiter.checkAndRecord(ip: "10.0.0.4", success: false) - - guard let initialRetry = expectRateLimited(lockResult) else { return } - - let retryResult = await limiter.checkAndRecord(ip: "10.0.0.4", success: false) - guard let remainingRetry = expectRateLimited(retryResult, message: "Should still be locked") else { return } - - #expect(remainingRetry <= initialRetry) - } - - @Test("isLockedOut returns rateLimited during lockout") - func isLockedOutDuringLockout() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.1.1", success: false) - _ = await limiter.checkAndRecord(ip: "10.0.1.1", success: false) - - let result = await limiter.isLockedOut(ip: "10.0.1.1") - expectRateLimited(result, message: "Should be locked out after 2 failures") - } - - @Test("isLockedOut returns allowed when not locked") - func isLockedOutWhenNotLocked() async { - let limiter = makeLimiter() - let result = await limiter.isLockedOut(ip: "fresh-ip") - expectAllowed(result) - } - - @Test("Different IPs have independent counters") - func independentCounters() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "ip-a", success: false) - _ = await limiter.checkAndRecord(ip: "ip-a", success: false) - - let lockedResult = await limiter.isLockedOut(ip: "ip-a") - expectRateLimited(lockedResult, message: "IP-A should be locked") - - let resultB = await limiter.checkAndRecord(ip: "ip-b", success: false) - expectAllowed(resultB, message: "IP-B should be independent of IP-A") - } - - @Test("Locking one IP does not affect another") - func lockingIsolation() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "ip-a", success: false) - _ = await limiter.checkAndRecord(ip: "ip-a", success: false) - - let lockedResult = await limiter.isLockedOut(ip: "ip-a") - expectRateLimited(lockedResult, message: "IP-A should be locked") - - let resultB = await limiter.checkAndRecord(ip: "ip-b", success: false) - expectAllowed(resultB, message: "IP-B should not be affected by IP-A lockout") - } - - @Test("Success after failure resets counter") - func successResetsCounter() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.2.1", success: false) - _ = await limiter.checkAndRecord(ip: "10.0.2.1", success: true) - - let firstFail = await limiter.checkAndRecord(ip: "10.0.2.1", success: false) - expectAllowed(firstFail, message: "Counter should reset after success, so first failure again is allowed") - - let secondFail = await limiter.checkAndRecord(ip: "10.0.2.1", success: false) - expectRateLimited(secondFail, message: "Second failure after reset should lock out again") - } - - @Test("Empty IP string works") - func emptyIpString() async { - let limiter = makeLimiter() - let result = await limiter.checkAndRecord(ip: "", success: false) - expectAllowed(result, message: "First failure for empty IP should be allowed") - } - - @Test("Success on first call returns allowed without prior record") - func successOnFirstCall() async { - let limiter = makeLimiter() - let result = await limiter.checkAndRecord(ip: "10.0.3.1", success: true) - expectAllowed(result) - } - - @Test("Rapid sequential failures while locked do not escalate") - func rapidSequentialFailuresWhileLocked() async { - let limiter = makeLimiter() - let ip = "10.0.3.2" - - let result1 = await limiter.checkAndRecord(ip: ip, success: false) - expectAllowed(result1, message: "Failure 1 should be allowed") - - let result2 = await limiter.checkAndRecord(ip: ip, success: false) - guard let retry2 = expectRateLimited(result2, message: "Failure 2 should trigger lockout") else { return } - #expect(retry2.components.seconds >= 0 && retry2.components.seconds <= 2) - - let result3 = await limiter.checkAndRecord(ip: ip, success: false) - guard let retry3 = expectRateLimited(result3, message: "Failure 3 while locked returns remaining time") else { return } - #expect(retry3 <= retry2) - - let result4 = await limiter.checkAndRecord(ip: ip, success: false) - guard let retry4 = expectRateLimited(result4, message: "Failure 4 while locked returns remaining time") else { return } - #expect(retry4 <= retry3) - } - - @Test("isLockedOut returns allowed after single failure with no lockout") - func isLockedOutAfterSingleFailure() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.4.1", success: false) - let result = await limiter.isLockedOut(ip: "10.0.4.1") - expectAllowed(result, message: "Single failure sets no lockout") - } -} diff --git a/TableProTests/Core/MCP/MCPAuthGuardTests.swift b/TableProTests/Core/MCP/MCPAuthGuardTests.swift deleted file mode 100644 index be9540422..000000000 --- a/TableProTests/Core/MCP/MCPAuthGuardTests.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// MCPAuthGuardTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -@Suite("MCP Auth Guard external access", .serialized) -@MainActor -struct MCPAuthGuardTests { - private let storage = ConnectionStorage.shared - - private func withConnection( - externalAccess: ExternalAccessLevel, - aiPolicy: AIConnectionPolicy = .alwaysAllow, - body: (UUID) async throws -> Void - ) async throws { - let original = storage.loadConnections() - defer { storage.saveConnections(original) } - - let connection = DatabaseConnection( - name: "MCP Test", - type: .mysql, - aiPolicy: aiPolicy, - externalAccess: externalAccess - ) - storage.saveConnections([connection]) - try await body(connection.id) - } - - @Test("Read query passes when externalAccess is readOnly") - func readQueryReadOnly() async throws { - try await withConnection(externalAccess: .readOnly) { connectionId in - let guardian = MCPAuthGuard() - try await guardian.checkExternalWritePermission( - connectionId: connectionId, - sql: "SELECT * FROM users", - databaseType: .mysql - ) - } - } - - @Test("Write query is blocked when externalAccess is readOnly") - func writeQueryBlockedReadOnly() async throws { - try await withConnection(externalAccess: .readOnly) { connectionId in - let guardian = MCPAuthGuard() - do { - try await guardian.checkExternalWritePermission( - connectionId: connectionId, - sql: "UPDATE users SET name='x' WHERE id=1", - databaseType: .mysql - ) - Issue.record("Expected MCPError.forbidden for write on read-only connection") - } catch let error as MCPError { - if case .forbidden = error { - return - } - Issue.record("Expected forbidden, got \(error)") - } - } - } - - @Test("Write query passes when externalAccess is readWrite") - func writeQueryAllowedReadWrite() async throws { - try await withConnection(externalAccess: .readWrite) { connectionId in - let guardian = MCPAuthGuard() - try await guardian.checkExternalWritePermission( - connectionId: connectionId, - sql: "INSERT INTO users (id) VALUES (1)", - databaseType: .mysql - ) - } - } - - @Test("Connection access blocked when externalAccess is blocked") - func connectionAccessBlocked() async throws { - try await withConnection(externalAccess: .blocked) { connectionId in - let guardian = MCPAuthGuard() - do { - try await guardian.checkConnectionAccess( - connectionId: connectionId, - sessionId: "session-1" - ) - Issue.record("Expected MCPError.forbidden for blocked connection") - } catch let error as MCPError { - if case .forbidden = error { - return - } - Issue.record("Expected forbidden, got \(error)") - } - } - } - - @Test("Connection access allowed when externalAccess is readOnly") - func connectionAccessAllowedReadOnly() async throws { - try await withConnection(externalAccess: .readOnly) { connectionId in - let guardian = MCPAuthGuard() - try await guardian.checkConnectionAccess( - connectionId: connectionId, - sessionId: "session-1" - ) - } - } - - @Test("Missing connection rejects external write check") - func missingConnectionRejectsExternalWrite() async { - let guardian = MCPAuthGuard() - let unknownId = UUID() - do { - try await guardian.checkExternalWritePermission( - connectionId: unknownId, - sql: "UPDATE foo SET bar=1", - databaseType: .mysql - ) - Issue.record("Expected MCPError.forbidden for missing connection") - } catch let error as MCPError { - if case .forbidden = error { - return - } - Issue.record("Expected forbidden, got \(error)") - } catch { - Issue.record("Unexpected error type: \(error)") - } - } -} diff --git a/TableProTests/Core/MCP/MCPRateLimiterTests.swift b/TableProTests/Core/MCP/MCPRateLimiterTests.swift deleted file mode 100644 index 95830f5c4..000000000 --- a/TableProTests/Core/MCP/MCPRateLimiterTests.swift +++ /dev/null @@ -1,216 +0,0 @@ -// -// MCPRateLimiterTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("MCP Rate Limiter") -struct MCPRateLimiterTests { - private func makeLimiter() -> MCPRateLimiter { - MCPRateLimiter() - } - - private func expectAllowed(_ result: MCPRateLimiter.AuthRateResult, message: String = "") { - guard case .allowed = result else { - Issue.record("Expected .allowed but got \(result). \(message)") - return - } - } - - @discardableResult - private func expectRateLimited(_ result: MCPRateLimiter.AuthRateResult, message: String = "") -> Duration? { - guard case .rateLimited(let retryAfter) = result else { - Issue.record("Expected .rateLimited but got \(result). \(message)") - return nil - } - return retryAfter - } - - @Test("First request is allowed") - func firstRequestAllowed() async { - let limiter = makeLimiter() - let result = await limiter.checkAndRecord(ip: "1.2.3.4", success: false) - expectAllowed(result) - } - - @Test("Success clears failure record") - func successClearsFailureRecord() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "1.2.3.4", success: false) - _ = await limiter.checkAndRecord(ip: "1.2.3.4", success: true) - let result = await limiter.checkAndRecord(ip: "1.2.3.4", success: false) - expectAllowed(result, message: "Counter should have been reset by success") - } - - @Test("Unknown IP is allowed") - func unknownIpAllowed() async { - let limiter = makeLimiter() - let result = await limiter.checkAndRecord(ip: "never-seen-before", success: false) - expectAllowed(result) - } - - @Test("isLockedOut for unknown IP returns allowed") - func isLockedOutUnknownIp() async { - let limiter = makeLimiter() - let result = await limiter.isLockedOut(ip: "unknown") - expectAllowed(result) - } - - @Test("Second failure triggers 1s lockout") - func secondFailureLockout() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.0.1", success: false) - let result = await limiter.checkAndRecord(ip: "10.0.0.1", success: false) - - guard let retryAfter = expectRateLimited(result, message: "Second failure should lock out") else { return } - let seconds = retryAfter.components.seconds - #expect(seconds >= 0 && seconds <= 2) - } - - @Test("Third failure triggers 5s lockout after previous lockout expires") - func thirdFailureLockout() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.0.2", success: false) - _ = await limiter.checkAndRecord(ip: "10.0.0.2", success: false) - - try? await Task.sleep(for: .seconds(1.1)) - - let result = await limiter.checkAndRecord(ip: "10.0.0.2", success: false) - guard let retryAfter = expectRateLimited(result, message: "Third failure should lock out for ~5s") else { return } - let seconds = retryAfter.components.seconds - #expect(seconds >= 4 && seconds <= 6) - } - - @Test("Fourth failure triggers 30s lockout") - func fourthFailureLockout() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) - _ = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) - - try? await Task.sleep(for: .seconds(1.1)) - _ = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) - - try? await Task.sleep(for: .seconds(5.1)) - let result = await limiter.checkAndRecord(ip: "10.0.0.3", success: false) - - guard let retryAfter = expectRateLimited(result, message: "Fourth failure should lock out for ~30s") else { return } - let seconds = retryAfter.components.seconds - #expect(seconds >= 28 && seconds <= 32) - } - - @Test("Repeated failures while locked return remaining lockout time") - func repeatedFailuresWhileLocked() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.0.4", success: false) - let lockResult = await limiter.checkAndRecord(ip: "10.0.0.4", success: false) - - guard let initialRetry = expectRateLimited(lockResult) else { return } - - let retryResult = await limiter.checkAndRecord(ip: "10.0.0.4", success: false) - guard let remainingRetry = expectRateLimited(retryResult, message: "Should still be locked") else { return } - - #expect(remainingRetry <= initialRetry) - } - - @Test("isLockedOut returns rateLimited during lockout") - func isLockedOutDuringLockout() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.1.1", success: false) - _ = await limiter.checkAndRecord(ip: "10.0.1.1", success: false) - - let result = await limiter.isLockedOut(ip: "10.0.1.1") - expectRateLimited(result, message: "Should be locked out after 2 failures") - } - - @Test("isLockedOut returns allowed when not locked") - func isLockedOutWhenNotLocked() async { - let limiter = makeLimiter() - let result = await limiter.isLockedOut(ip: "fresh-ip") - expectAllowed(result) - } - - @Test("Different IPs have independent counters") - func independentCounters() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "ip-a", success: false) - _ = await limiter.checkAndRecord(ip: "ip-a", success: false) - - let lockedResult = await limiter.isLockedOut(ip: "ip-a") - expectRateLimited(lockedResult, message: "IP-A should be locked") - - let resultB = await limiter.checkAndRecord(ip: "ip-b", success: false) - expectAllowed(resultB, message: "IP-B should be independent of IP-A") - } - - @Test("Locking one IP does not affect another") - func lockingIsolation() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "ip-a", success: false) - _ = await limiter.checkAndRecord(ip: "ip-a", success: false) - - let lockedResult = await limiter.isLockedOut(ip: "ip-a") - expectRateLimited(lockedResult, message: "IP-A should be locked") - - let resultB = await limiter.checkAndRecord(ip: "ip-b", success: false) - expectAllowed(resultB, message: "IP-B should not be affected by IP-A lockout") - } - - @Test("Success after failure resets counter") - func successResetsCounter() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.2.1", success: false) - _ = await limiter.checkAndRecord(ip: "10.0.2.1", success: true) - - let firstFail = await limiter.checkAndRecord(ip: "10.0.2.1", success: false) - expectAllowed(firstFail, message: "Counter should reset after success, so first failure again is allowed") - - let secondFail = await limiter.checkAndRecord(ip: "10.0.2.1", success: false) - expectRateLimited(secondFail, message: "Second failure after reset should lock out again") - } - - @Test("Empty IP string works") - func emptyIpString() async { - let limiter = makeLimiter() - let result = await limiter.checkAndRecord(ip: "", success: false) - expectAllowed(result, message: "First failure for empty IP should be allowed") - } - - @Test("Success on first call returns allowed without prior record") - func successOnFirstCall() async { - let limiter = makeLimiter() - let result = await limiter.checkAndRecord(ip: "10.0.3.1", success: true) - expectAllowed(result) - } - - @Test("Rapid sequential failures while locked do not escalate") - func rapidSequentialFailuresWhileLocked() async { - let limiter = makeLimiter() - let ip = "10.0.3.2" - - let result1 = await limiter.checkAndRecord(ip: ip, success: false) - expectAllowed(result1, message: "Failure 1 should be allowed") - - let result2 = await limiter.checkAndRecord(ip: ip, success: false) - guard let retry2 = expectRateLimited(result2, message: "Failure 2 should trigger lockout") else { return } - #expect(retry2.components.seconds >= 0 && retry2.components.seconds <= 2) - - let result3 = await limiter.checkAndRecord(ip: ip, success: false) - guard let retry3 = expectRateLimited(result3, message: "Failure 3 while locked returns remaining time") else { return } - #expect(retry3 <= retry2) - - let result4 = await limiter.checkAndRecord(ip: ip, success: false) - guard let retry4 = expectRateLimited(result4, message: "Failure 4 while locked returns remaining time") else { return } - #expect(retry4 <= retry3) - } - - @Test("isLockedOut returns allowed after single failure with no lockout") - func isLockedOutAfterSingleFailure() async { - let limiter = makeLimiter() - _ = await limiter.checkAndRecord(ip: "10.0.4.1", success: false) - let result = await limiter.isLockedOut(ip: "10.0.4.1") - expectAllowed(result, message: "Single failure sets no lockout") - } -} diff --git a/TableProTests/Core/MCP/MCPRouterTests.swift b/TableProTests/Core/MCP/MCPRouterTests.swift deleted file mode 100644 index f2c392eae..000000000 --- a/TableProTests/Core/MCP/MCPRouterTests.swift +++ /dev/null @@ -1,167 +0,0 @@ -// -// MCPRouterTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("MCP Router") -struct MCPRouterTests { - private final class StubHandler: MCPRouteHandler, @unchecked Sendable { - let methods: [HTTPRequest.Method] - let path: String - private let result: MCPRouter.RouteResult - private(set) var invocationCount: Int = 0 - private(set) var lastRequest: HTTPRequest? - - init(methods: [HTTPRequest.Method], path: String, result: MCPRouter.RouteResult = .accepted) { - self.methods = methods - self.path = path - self.result = result - } - - func handle(_ request: HTTPRequest) async -> MCPRouter.RouteResult { - invocationCount += 1 - lastRequest = request - return result - } - } - - private func makeRequest( - method: HTTPRequest.Method, - path: String, - body: Data? = nil - ) -> HTTPRequest { - HTTPRequest(method: method, path: path, headers: [:], body: body, remoteIP: nil) - } - - @Test("OPTIONS preflight returns noContent regardless of path") - func optionsPreflightAlwaysNoContent() async { - let mcpHandler = StubHandler(methods: [.post], path: "/mcp", result: .accepted) - let router = MCPRouter(routes: [mcpHandler]) - - let optionsAtMcp = makeRequest(method: .options, path: "/mcp") - let result1 = await router.handle(optionsAtMcp) - guard case .noContent = result1 else { - Issue.record("Expected .noContent for OPTIONS /mcp, got \(result1)") - return - } - - let optionsAtUnknown = makeRequest(method: .options, path: "/unknown/path") - let result2 = await router.handle(optionsAtUnknown) - guard case .noContent = result2 else { - Issue.record("Expected .noContent for OPTIONS /unknown, got \(result2)") - return - } - - #expect(mcpHandler.invocationCount == 0) - } - - @Test("POST /mcp dispatches to MCP protocol handler") - func postMcpDispatchesToProtocolHandler() async { - let mcpHandler = StubHandler(methods: [.get, .post, .delete], path: "/mcp", result: .accepted) - let exchangeHandler = StubHandler(methods: [.post], path: "/v1/integrations/exchange", result: .accepted) - let router = MCPRouter(routes: [mcpHandler, exchangeHandler]) - - let request = makeRequest(method: .post, path: "/mcp") - _ = await router.handle(request) - - #expect(mcpHandler.invocationCount == 1) - #expect(exchangeHandler.invocationCount == 0) - } - - @Test("POST /v1/integrations/exchange dispatches to exchange handler") - func postExchangeDispatchesToExchangeHandler() async { - let mcpHandler = StubHandler(methods: [.get, .post, .delete], path: "/mcp", result: .accepted) - let exchangeHandler = StubHandler(methods: [.post], path: "/v1/integrations/exchange", result: .accepted) - let router = MCPRouter(routes: [mcpHandler, exchangeHandler]) - - let request = makeRequest(method: .post, path: "/v1/integrations/exchange") - _ = await router.handle(request) - - #expect(exchangeHandler.invocationCount == 1) - #expect(mcpHandler.invocationCount == 0) - } - - @Test("Path with query string still matches canonical route") - func queryStringMatchesCanonicalPath() async { - let mcpHandler = StubHandler(methods: [.post], path: "/mcp", result: .accepted) - let router = MCPRouter(routes: [mcpHandler]) - - let request = makeRequest(method: .post, path: "/mcp?session=abc") - _ = await router.handle(request) - - #expect(mcpHandler.invocationCount == 1) - } - - @Test("Unknown path returns 404 httpError") - func unknownPathReturnsNotFound() async { - let mcpHandler = StubHandler(methods: [.post], path: "/mcp", result: .accepted) - let router = MCPRouter(routes: [mcpHandler]) - - let request = makeRequest(method: .post, path: "/totally/unknown") - let result = await router.handle(request) - - guard case .httpError(let status, _) = result else { - Issue.record("Expected .httpError, got \(result)") - return - } - #expect(status == 404) - #expect(mcpHandler.invocationCount == 0) - } - - @Test("Method mismatch on registered path returns 404") - func methodMismatchReturnsNotFound() async { - let exchangeHandler = StubHandler(methods: [.post], path: "/v1/integrations/exchange", result: .accepted) - let router = MCPRouter(routes: [exchangeHandler]) - - let request = makeRequest(method: .get, path: "/v1/integrations/exchange") - let result = await router.handle(request) - - guard case .httpError(let status, _) = result else { - Issue.record("Expected .httpError, got \(result)") - return - } - #expect(status == 404) - #expect(exchangeHandler.invocationCount == 0) - } - - @Test(".well-known requests return 404 immediately") - func wellKnownReturnsNotFound() async { - let mcpHandler = StubHandler(methods: [.get], path: "/.well-known/oauth", result: .accepted) - let router = MCPRouter(routes: [mcpHandler]) - - let request = makeRequest(method: .get, path: "/.well-known/oauth") - let result = await router.handle(request) - - guard case .httpError(let status, _) = result else { - Issue.record("Expected .httpError, got \(result)") - return - } - #expect(status == 404) - #expect(mcpHandler.invocationCount == 0) - } - - @Test("Handler receives the original request") - func handlerReceivesOriginalRequest() async { - let mcpHandler = StubHandler(methods: [.post], path: "/mcp", result: .accepted) - let router = MCPRouter(routes: [mcpHandler]) - - let body = Data("{\"hello\":\"world\"}".utf8) - let request = HTTPRequest( - method: .post, - path: "/mcp", - headers: ["content-type": "application/json"], - body: body, - remoteIP: "10.0.0.1" - ) - _ = await router.handle(request) - - #expect(mcpHandler.lastRequest?.path == "/mcp") - #expect(mcpHandler.lastRequest?.method == .post) - #expect(mcpHandler.lastRequest?.body == body) - #expect(mcpHandler.lastRequest?.remoteIP == "10.0.0.1") - } -} diff --git a/TableProTests/Core/MCP/MCPTokenStoreTests.swift b/TableProTests/Core/MCP/MCPTokenStoreTests.swift index 757a9791c..04abdf6cd 100644 --- a/TableProTests/Core/MCP/MCPTokenStoreTests.swift +++ b/TableProTests/Core/MCP/MCPTokenStoreTests.swift @@ -20,7 +20,7 @@ struct MCPTokenStoreTests { tokenHash: "fakehash", salt: "fakesalt", permissions: .readOnly, - allowedConnectionIds: nil, + connectionAccess: .all, createdAt: Date.now, lastUsedAt: nil, expiresAt: expiresAt, @@ -183,23 +183,31 @@ struct MCPTokenStoreTests { #expect(result.token.expiresAt != nil) } - @Test("generate with nil connectionIds stores nil") - func generateWithNilConnectionIds() async { + @Test("generate with .all access stores .all") + func generateWithAllAccess() async { let store = makeStore() - let result = await store.generate(name: "test", permissions: .readOnly, allowedConnectionIds: nil) + let result = await store.generate(name: "test", permissions: .readOnly, connectionAccess: .all) await store.delete(tokenId: result.token.id) - #expect(result.token.allowedConnectionIds == nil) + #expect(result.token.connectionAccess == .all) } - @Test("generate with specific connectionIds stores them") - func generateWithSpecificConnectionIds() async { + @Test("generate with .limited stores the connection ids") + func generateWithLimitedAccess() async { let ids: Set = [UUID(), UUID()] let store = makeStore() - let result = await store.generate(name: "test", permissions: .readOnly, allowedConnectionIds: ids) + let result = await store.generate( + name: "test", + permissions: .readOnly, + connectionAccess: .limited(ids) + ) await store.delete(tokenId: result.token.id) - #expect(result.token.allowedConnectionIds == ids) + if case .limited(let stored) = result.token.connectionAccess { + #expect(stored == ids) + } else { + Issue.record("Expected .limited connection access") + } } @Test("validate returns token for valid bearer") diff --git a/TableProTests/Core/MCP/MCPToolHandlerExportTests.swift b/TableProTests/Core/MCP/MCPToolHandlerExportTests.swift deleted file mode 100644 index b1be3ae00..000000000 --- a/TableProTests/Core/MCP/MCPToolHandlerExportTests.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// MCPToolHandlerExportTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -@Suite("MCP Tool Handler — export_data validation", .serialized) -@MainActor -struct MCPToolHandlerExportTests { - private let storage = ConnectionStorage.shared - - private func makeHandler() -> MCPToolHandler { - MCPToolHandler(bridge: MCPConnectionBridge(), authGuard: MCPAuthGuard()) - } - - private func withConnections( - _ connections: [DatabaseConnection], - body: () async throws -> Void - ) async throws { - let original = storage.loadConnections() - defer { storage.saveConnections(original) } - storage.saveConnections(connections) - try await body() - } - - @Test("export_data rejects table name with SQL injection payload") - func exportDataRejectsInjectionInTableName() async throws { - let handler = makeHandler() - let connection = DatabaseConnection( - name: "Target", - type: .mysql, - aiPolicy: .alwaysAllow, - externalAccess: .readWrite - ) - try await withConnections([connection]) { - do { - _ = try await handler.handleToolCall( - name: "export_data", - arguments: .object([ - "connection_id": .string(connection.id.uuidString), - "format": .string("csv"), - "tables": .array([.string("users; DROP TABLE users;--")]) - ]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.invalidParams for malicious table name") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - } - - @Test("export_data rejects table name with quote payload") - func exportDataRejectsQuotePayload() async throws { - let handler = makeHandler() - let connection = DatabaseConnection( - name: "Target", - type: .mysql, - aiPolicy: .alwaysAllow, - externalAccess: .readWrite - ) - try await withConnections([connection]) { - do { - _ = try await handler.handleToolCall( - name: "export_data", - arguments: .object([ - "connection_id": .string(connection.id.uuidString), - "format": .string("csv"), - "tables": .array([.string("users`; DROP TABLE x;--")]) - ]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.invalidParams for backtick injection") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - } - - @Test("validateExportTableName accepts simple identifiers") - func validateExportTableNameAcceptsSimple() throws { - try MCPToolHandler.validateExportTableName("users") - try MCPToolHandler.validateExportTableName("users_v2") - try MCPToolHandler.validateExportTableName("public.users") - try MCPToolHandler.validateExportTableName("schema.table_name_42") - } - - @Test("validateExportTableName rejects spaces") - func validateExportTableNameRejectsSpaces() { - do { - try MCPToolHandler.validateExportTableName("users x") - Issue.record("Expected throw for table name with space") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("validateExportTableName rejects semicolon") - func validateExportTableNameRejectsSemicolon() { - do { - try MCPToolHandler.validateExportTableName("users;DROP TABLE x") - Issue.record("Expected throw for table name with semicolon") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("validateExportTableName rejects empty string") - func validateExportTableNameRejectsEmpty() { - do { - try MCPToolHandler.validateExportTableName("") - Issue.record("Expected throw for empty table name") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("validateExportTableName rejects leading dot") - func validateExportTableNameRejectsLeadingDot() { - do { - try MCPToolHandler.validateExportTableName(".users") - Issue.record("Expected throw for table name with leading dot") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("export_data rejects output_path outside Downloads") - func exportDataRejectsPathOutsideDownloads() async throws { - let handler = makeHandler() - let connection = DatabaseConnection( - name: "Target", - type: .mysql, - aiPolicy: .alwaysAllow, - externalAccess: .readWrite - ) - try await withConnections([connection]) { - do { - _ = try await handler.handleToolCall( - name: "export_data", - arguments: .object([ - "connection_id": .string(connection.id.uuidString), - "format": .string("csv"), - "query": .string("SELECT 1"), - "output_path": .string("/tmp/escape.csv") - ]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.invalidParams for path outside Downloads") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - } -} diff --git a/TableProTests/Core/MCP/MCPToolHandlerIntegrationTests.swift b/TableProTests/Core/MCP/MCPToolHandlerIntegrationTests.swift deleted file mode 100644 index 73eb404be..000000000 --- a/TableProTests/Core/MCP/MCPToolHandlerIntegrationTests.swift +++ /dev/null @@ -1,701 +0,0 @@ -// -// MCPToolHandlerIntegrationTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -@Suite("MCP Tool Handler — integration tools", .serialized) -@MainActor -struct MCPToolHandlerIntegrationTests { - private let storage = ConnectionStorage.shared - - private func makeHandler() -> MCPToolHandler { - MCPToolHandler(bridge: MCPConnectionBridge(), authGuard: MCPAuthGuard()) - } - - private func makeToken( - permissions: TokenPermissions = .readWrite, - allowedConnectionIds: Set? = nil - ) -> MCPAuthToken { - MCPAuthToken( - id: UUID(), - name: "test-token", - prefix: "tp_test1", - tokenHash: "fakehash", - salt: "fakesalt", - permissions: permissions, - allowedConnectionIds: allowedConnectionIds, - createdAt: Date.now, - lastUsedAt: nil, - expiresAt: nil, - isActive: true - ) - } - - private func withConnections( - _ connections: [DatabaseConnection], - body: () async throws -> Void - ) async throws { - let original = storage.loadConnections() - defer { storage.saveConnections(original) } - storage.saveConnections(connections) - try await body() - } - - @Test("list_connections omits connections with externalAccess == .blocked") - func listConnectionsFiltersBlocked() async throws { - let handler = makeHandler() - let blocked = DatabaseConnection(name: "Blocked Prod", type: .mysql, externalAccess: .blocked) - let visible = DatabaseConnection(name: "Visible Staging", type: .mysql, externalAccess: .readOnly) - try await withConnections([blocked, visible]) { - let result = try await handler.handleToolCall( - name: "list_connections", - arguments: nil, - sessionId: "test-session", - token: nil - ) - #expect(result.isError == nil) - let payload = result.content.first?.text ?? "" - #expect(!payload.contains(blocked.id.uuidString)) - #expect(payload.contains(visible.id.uuidString)) - } - } - - @Test("list_recent_tabs returns tabs JSON object") - func listRecentTabsShape() async throws { - let handler = makeHandler() - let result = try await handler.handleToolCall( - name: "list_recent_tabs", - arguments: .object(["limit": .int(5)]), - sessionId: "test-session", - token: nil - ) - #expect(result.isError == nil) - #expect(result.content.first?.type == "text") - let payload = result.content.first?.text ?? "" - #expect(payload.contains("\"tabs\"")) - } - - @Test("blockedExternalConnectionIds returns ids of connections with externalAccess == .blocked") - func blockedExternalConnectionIdsHelper() async throws { - let blocked = DatabaseConnection(name: "Blocked", type: .mysql, externalAccess: .blocked) - let readOnly = DatabaseConnection(name: "ReadOnly", type: .mysql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) - let readWrite = DatabaseConnection(name: "ReadWrite", type: .mysql, externalAccess: .readWrite) - try await withConnections([blocked, readOnly, readWrite]) { - let ids = MCPToolHandler.blockedExternalConnectionIds() - #expect(ids.contains(blocked.id)) - #expect(!ids.contains(readOnly.id)) - #expect(!ids.contains(readWrite.id)) - } - } - - @Test("list_recent_tabs requires read scope only") - func listRecentTabsScope() async throws { - let handler = makeHandler() - let token = makeToken(permissions: .readOnly) - let result = try await handler.handleToolCall( - name: "list_recent_tabs", - arguments: nil, - sessionId: "test-session", - token: token - ) - #expect(result.isError == nil) - } - - @Test("search_query_history rejects missing query parameter") - func searchQueryHistoryRequiresQuery() async { - let handler = makeHandler() - do { - _ = try await handler.handleToolCall( - name: "search_query_history", - arguments: nil, - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.invalidParams when query is missing") - } catch let error as MCPError { - if case .invalidParams = error { - return - } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("search_query_history rejects invalid connection_id UUID") - func searchQueryHistoryRejectsInvalidUUID() async { - let handler = makeHandler() - do { - _ = try await handler.handleToolCall( - name: "search_query_history", - arguments: .object([ - "query": .string("SELECT"), - "connection_id": .string("not-a-uuid") - ]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.invalidParams for malformed UUID") - } catch let error as MCPError { - if case .invalidParams = error { - return - } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("search_query_history with empty query returns entries object") - func searchQueryHistoryEmptyQuery() async throws { - let handler = makeHandler() - let result = try await handler.handleToolCall( - name: "search_query_history", - arguments: .object(["query": .string(""), "limit": .int(1)]), - sessionId: "test-session", - token: nil - ) - #expect(result.isError == nil) - let payload = result.content.first?.text ?? "" - #expect(payload.contains("\"entries\"")) - } - - @Test("search_query_history rejects since greater than until") - func searchQueryHistoryRejectsInvertedWindow() async { - let handler = makeHandler() - do { - _ = try await handler.handleToolCall( - name: "search_query_history", - arguments: .object([ - "query": .string(""), - "since": .double(2_000), - "until": .double(1_000) - ]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.invalidParams when since > until") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("search_query_history rejects connection_id whose externalAccess is .blocked") - func searchQueryHistoryRejectsBlockedConnection() async throws { - let handler = makeHandler() - let blocked = DatabaseConnection(name: "Blocked Prod", type: .mysql, externalAccess: .blocked) - try await withConnections([blocked]) { - do { - _ = try await handler.handleToolCall( - name: "search_query_history", - arguments: .object([ - "query": .string(""), - "connection_id": .string(blocked.id.uuidString) - ]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.forbidden for blocked connection") - } catch let error as MCPError { - if case .forbidden = error { return } - Issue.record("Expected forbidden, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - } - - @Test("search_query_history filters out blocked connections when iterating without connection_id") - func searchQueryHistoryFiltersBlockedFromUnscopedQuery() async throws { - let handler = makeHandler() - let blocked = DatabaseConnection(name: "Blocked", type: .mysql, externalAccess: .blocked) - let visible = DatabaseConnection(name: "Visible", type: .mysql, externalAccess: .readOnly) - let marker = UUID().uuidString - - try await withConnections([blocked, visible]) { - let blockedEntry = QueryHistoryEntry( - query: "SELECT blocked_\(marker)", - connectionId: blocked.id, - databaseName: "db", - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - ) - let visibleEntry = QueryHistoryEntry( - query: "SELECT visible_\(marker)", - connectionId: visible.id, - databaseName: "db", - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - ) - _ = await QueryHistoryStorage.shared.addHistory(blockedEntry) - _ = await QueryHistoryStorage.shared.addHistory(visibleEntry) - - let result = try await handler.handleToolCall( - name: "search_query_history", - arguments: .object(["query": .string(marker)]), - sessionId: "test-session", - token: nil - ) - #expect(result.isError == nil) - let payload = result.content.first?.text ?? "" - #expect(payload.contains("visible_\(marker)")) - #expect(!payload.contains("blocked_\(marker)")) - } - } - - @Test("search_query_history pushes token allowlist into SQL so older allowed entries surface") - func searchQueryHistoryAllowlistOverFlood() async throws { - let handler = makeHandler() - let allowedConn = DatabaseConnection(name: "Allowed", type: .mysql) - let otherConn = DatabaseConnection(name: "Other", type: .mysql) - let marker = UUID().uuidString - let now = Date() - - try await withConnections([allowedConn, otherConn]) { - let oldAllowed = QueryHistoryEntry( - query: "SELECT old_allowed_\(marker)", - connectionId: allowedConn.id, - databaseName: "db", - executedAt: now.addingTimeInterval(-3_600), - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - ) - _ = await QueryHistoryStorage.shared.addHistory(oldAllowed) - - for index in 0..<20 { - let recentOther = QueryHistoryEntry( - query: "SELECT recent_other_\(marker)_\(index)", - connectionId: otherConn.id, - databaseName: "db", - executedAt: now.addingTimeInterval(Double(index)), - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - ) - _ = await QueryHistoryStorage.shared.addHistory(recentOther) - } - - let token = makeToken(allowedConnectionIds: [allowedConn.id]) - let result = try await handler.handleToolCall( - name: "search_query_history", - arguments: .object(["query": .string(marker), "limit": .int(5)]), - sessionId: "test-session", - token: token - ) - #expect(result.isError == nil) - let payload = result.content.first?.text ?? "" - #expect(payload.contains("old_allowed_\(marker)")) - #expect(!payload.contains("recent_other_\(marker)")) - } - } - - @Test("QueryHistoryStorage.fetchHistory restricts results to allowedConnectionIds") - func fetchHistoryAllowlistFilters() async throws { - let allowedId = UUID() - let otherId = UUID() - let marker = UUID().uuidString - - let allowedEntry = QueryHistoryEntry( - query: "SELECT allowed_\(marker)", - connectionId: allowedId, - databaseName: "db", - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - ) - let otherEntry = QueryHistoryEntry( - query: "SELECT other_\(marker)", - connectionId: otherId, - databaseName: "db", - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - ) - _ = await QueryHistoryStorage.shared.addHistory(allowedEntry) - _ = await QueryHistoryStorage.shared.addHistory(otherEntry) - - let entries = await QueryHistoryStorage.shared.fetchHistory( - limit: 100, - searchText: marker, - allowedConnectionIds: [allowedId] - ) - - #expect(entries.contains { $0.query.contains("allowed_\(marker)") }) - #expect(!entries.contains { $0.query.contains("other_\(marker)") }) - } - - @Test("QueryHistoryStorage.fetchHistory returns empty when allowedConnectionIds is empty") - func fetchHistoryEmptyAllowlistReturnsEmpty() async throws { - let connectionId = UUID() - let marker = UUID().uuidString - let entry = QueryHistoryEntry( - query: "SELECT empty_allowlist_\(marker)", - connectionId: connectionId, - databaseName: "db", - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - ) - _ = await QueryHistoryStorage.shared.addHistory(entry) - - let entries = await QueryHistoryStorage.shared.fetchHistory( - limit: 100, - searchText: marker, - allowedConnectionIds: [] - ) - - #expect(entries.isEmpty) - } - - @Test("search_query_history with since/until filters by executed_at window") - func searchQueryHistorySinceUntilFilters() async throws { - let handler = makeHandler() - let connId = UUID() - let now = Date() - let oneHourAgo = now.addingTimeInterval(-3_600) - let twoHoursAgo = now.addingTimeInterval(-7_200) - let marker = UUID().uuidString - - let outside = QueryHistoryEntry( - query: "SELECT outside_\(marker)", - connectionId: connId, - databaseName: "testdb", - executedAt: twoHoursAgo, - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - ) - let inside = QueryHistoryEntry( - query: "SELECT inside_\(marker)", - connectionId: connId, - databaseName: "testdb", - executedAt: oneHourAgo, - executionTime: 0.01, - rowCount: 1, - wasSuccessful: true - ) - _ = await QueryHistoryStorage.shared.addHistory(outside) - _ = await QueryHistoryStorage.shared.addHistory(inside) - - let result = try await handler.handleToolCall( - name: "search_query_history", - arguments: .object([ - "query": .string(marker), - "connection_id": .string(connId.uuidString), - "since": .double(now.addingTimeInterval(-5_400).timeIntervalSince1970), - "until": .double(now.timeIntervalSince1970) - ]), - sessionId: "test-session", - token: nil - ) - #expect(result.isError == nil) - let payload = result.content.first?.text ?? "" - #expect(payload.contains("inside_\(marker)")) - #expect(!payload.contains("outside_\(marker)")) - } - - @Test("switch_database against a readOnly connection returns forbidden") - func switchDatabaseDeniedByReadOnlyExternalAccess() async throws { - let handler = makeHandler() - let connection = DatabaseConnection(name: "ReadOnly", type: .mysql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) - try await withConnections([connection]) { - do { - _ = try await handler.handleToolCall( - name: "switch_database", - arguments: .object([ - "connection_id": .string(connection.id.uuidString), - "database": .string("postgres") - ]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.forbidden for readOnly externalAccess") - } catch let error as MCPError { - if case .forbidden = error { return } - Issue.record("Expected forbidden, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - } - - @Test("switch_schema against a readOnly connection returns forbidden") - func switchSchemaDeniedByReadOnlyExternalAccess() async throws { - let handler = makeHandler() - let connection = DatabaseConnection(name: "ReadOnly", type: .postgresql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) - try await withConnections([connection]) { - do { - _ = try await handler.handleToolCall( - name: "switch_schema", - arguments: .object([ - "connection_id": .string(connection.id.uuidString), - "schema": .string("public") - ]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.forbidden for readOnly externalAccess") - } catch let error as MCPError { - if case .forbidden = error { return } - Issue.record("Expected forbidden, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - } - - @Test("export_data against a readOnly connection returns forbidden") - func exportDataDeniedByReadOnlyExternalAccess() async throws { - let handler = makeHandler() - let connection = DatabaseConnection(name: "ReadOnly", type: .mysql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) - try await withConnections([connection]) { - do { - _ = try await handler.handleToolCall( - name: "export_data", - arguments: .object([ - "connection_id": .string(connection.id.uuidString), - "format": .string("csv"), - "tables": .array([.string("users")]) - ]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.forbidden for readOnly externalAccess") - } catch let error as MCPError { - if case .forbidden = error { return } - Issue.record("Expected forbidden, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - } - - @Test("open_connection_window against a readOnly connection returns forbidden") - func openConnectionWindowDeniedByReadOnlyExternalAccess() async throws { - let handler = makeHandler() - let connection = DatabaseConnection(name: "ReadOnly", type: .mysql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) - try await withConnections([connection]) { - do { - _ = try await handler.handleToolCall( - name: "open_connection_window", - arguments: .object(["connection_id": .string(connection.id.uuidString)]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.forbidden for readOnly externalAccess") - } catch let error as MCPError { - if case .forbidden = error { return } - Issue.record("Expected forbidden, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - } - - @Test("open_table_tab against a readOnly connection returns forbidden") - func openTableTabDeniedByReadOnlyExternalAccess() async throws { - let handler = makeHandler() - let connection = DatabaseConnection(name: "ReadOnly", type: .mysql, aiPolicy: .alwaysAllow, externalAccess: .readOnly) - try await withConnections([connection]) { - do { - _ = try await handler.handleToolCall( - name: "open_table_tab", - arguments: .object([ - "connection_id": .string(connection.id.uuidString), - "table_name": .string("users") - ]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.forbidden for readOnly externalAccess") - } catch let error as MCPError { - if case .forbidden = error { return } - Issue.record("Expected forbidden, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - } - - @Test("ExternalAccessLevel.satisfies follows blocked < readOnly < readWrite ordering") - func externalAccessLevelSatisfiesOrdering() { - #expect(ExternalAccessLevel.readWrite.satisfies(.readWrite)) - #expect(ExternalAccessLevel.readWrite.satisfies(.readOnly)) - #expect(ExternalAccessLevel.readOnly.satisfies(.readOnly)) - #expect(!ExternalAccessLevel.readOnly.satisfies(.readWrite)) - #expect(!ExternalAccessLevel.blocked.satisfies(.readOnly)) - #expect(!ExternalAccessLevel.blocked.satisfies(.readWrite)) - } - - @Test("open_connection_window rejects missing connection_id") - func openConnectionWindowRequiresConnectionId() async { - let handler = makeHandler() - do { - _ = try await handler.handleToolCall( - name: "open_connection_window", - arguments: nil, - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.invalidParams") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("open_connection_window rejects unknown connection") - func openConnectionWindowRejectsUnknown() async throws { - let handler = makeHandler() - do { - _ = try await handler.handleToolCall( - name: "open_connection_window", - arguments: .object(["connection_id": .string(UUID().uuidString)]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.notFound for unknown connection") - } catch let error as MCPError { - if case .notFound = error { return } - Issue.record("Expected notFound, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("open_connection_window denies read-only token") - func openConnectionWindowReadOnlyDenied() async throws { - let handler = makeHandler() - let token = makeToken(permissions: .readOnly) - do { - _ = try await handler.handleToolCall( - name: "open_connection_window", - arguments: .object(["connection_id": .string(UUID().uuidString)]), - sessionId: "test-session", - token: token - ) - Issue.record("Expected MCPError.forbidden for read-only token") - } catch let error as MCPError { - if case .forbidden = error { return } - Issue.record("Expected forbidden, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("open_connection_window respects token connection allowlist") - func openConnectionWindowAllowlist() async throws { - let handler = makeHandler() - let connection = DatabaseConnection(name: "Test", type: .mysql) - try await withConnections([connection]) { - let token = makeToken( - permissions: .readWrite, - allowedConnectionIds: [UUID()] - ) - do { - _ = try await handler.handleToolCall( - name: "open_connection_window", - arguments: .object(["connection_id": .string(connection.id.uuidString)]), - sessionId: "test-session", - token: token - ) - Issue.record("Expected MCPError.forbidden for disallowed connection") - } catch let error as MCPError { - if case .forbidden = error { return } - Issue.record("Expected forbidden, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - } - - @Test("open_table_tab requires table_name") - func openTableTabRequiresTableName() async { - let handler = makeHandler() - do { - _ = try await handler.handleToolCall( - name: "open_table_tab", - arguments: .object(["connection_id": .string(UUID().uuidString)]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.invalidParams") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("focus_query_tab returns notFound when tab is not open") - func focusQueryTabNotFound() async { - let handler = makeHandler() - do { - _ = try await handler.handleToolCall( - name: "focus_query_tab", - arguments: .object(["tab_id": .string(UUID().uuidString)]), - sessionId: "test-session", - token: nil - ) - Issue.record("Expected MCPError.notFound") - } catch let error as MCPError { - if case .notFound = error { return } - Issue.record("Expected notFound, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("focus_query_tab requires read-write token") - func focusQueryTabRequiresWriteScope() async { - let handler = makeHandler() - let token = makeToken(permissions: .readOnly) - do { - _ = try await handler.handleToolCall( - name: "focus_query_tab", - arguments: .object(["tab_id": .string(UUID().uuidString)]), - sessionId: "test-session", - token: token - ) - Issue.record("Expected MCPError.forbidden for read-only token") - } catch let error as MCPError { - if case .forbidden = error { return } - Issue.record("Expected forbidden, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("Unknown tool name throws methodNotFound") - func unknownToolThrows() async { - let handler = makeHandler() - do { - _ = try await handler.handleToolCall( - name: "totally_made_up_tool", - arguments: nil, - sessionId: "test-session", - token: nil - ) - Issue.record("Expected methodNotFound") - } catch let error as MCPError { - if case .methodNotFound = error { return } - Issue.record("Expected methodNotFound, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } -} diff --git a/TableProTests/Core/MCP/MCPToolHandlerSecurityTests.swift b/TableProTests/Core/MCP/MCPToolHandlerSecurityTests.swift deleted file mode 100644 index 36c595ec5..000000000 --- a/TableProTests/Core/MCP/MCPToolHandlerSecurityTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation -import Testing - -@testable import TablePro - -@Suite("MCP Tool Handler — identifier validation hardening") -struct MCPToolHandlerSecurityTests { - @Test("validateExportTableName rejects double-dot") - func rejectsDoubleDot() { - do { - try MCPToolHandler.validateExportTableName("schema..table") - Issue.record("Expected throw for double-dot table name") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("validateExportTableName rejects trailing dot") - func rejectsTrailingDot() { - do { - try MCPToolHandler.validateExportTableName("schema.") - Issue.record("Expected throw for trailing-dot table name") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("validateExportTableName rejects only dots") - func rejectsOnlyDots() { - do { - try MCPToolHandler.validateExportTableName("..") - Issue.record("Expected throw for dots-only table name") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("validateExportTableName accepts schema-qualified identifiers") - func acceptsValidQualified() throws { - try MCPToolHandler.validateExportTableName("public.users") - try MCPToolHandler.validateExportTableName("db.schema.table") - } - - @Test("quoteQualifiedIdentifier throws on empty component") - func quoteThrowsOnEmptyComponent() { - let quoter: (String) -> String = { "\"\($0)\"" } - do { - _ = try MCPToolHandler.quoteQualifiedIdentifier("schema..table", quoter: quoter) - Issue.record("Expected throw for empty component in qualified identifier") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("quoteQualifiedIdentifier throws on leading dot") - func quoteThrowsOnLeadingDot() { - let quoter: (String) -> String = { "\"\($0)\"" } - do { - _ = try MCPToolHandler.quoteQualifiedIdentifier(".table", quoter: quoter) - Issue.record("Expected throw for leading-dot identifier") - } catch let error as MCPError { - if case .invalidParams = error { return } - Issue.record("Expected invalidParams, got \(error)") - } catch { - Issue.record("Expected MCPError, got \(error)") - } - } - - @Test("quoteQualifiedIdentifier quotes each segment for valid identifiers") - func quoteQuotesValidSegments() throws { - let quoter: (String) -> String = { "\"\($0)\"" } - let result = try MCPToolHandler.quoteQualifiedIdentifier("public.users", quoter: quoter) - #expect(result == "\"public\".\"users\"") - } -} diff --git a/TableProTests/Core/Services/ConnectionSharingTests.swift b/TableProTests/Core/Services/ConnectionSharingTests.swift index 1286eeb11..a331a8253 100644 --- a/TableProTests/Core/Services/ConnectionSharingTests.swift +++ b/TableProTests/Core/Services/ConnectionSharingTests.swift @@ -311,7 +311,7 @@ struct ConnectionSharingTests { ) let link = ConnectionExportService.buildImportDeeplink(for: original)! let url = URL(string: link)! - guard case .importConnection(let parsed) = DeeplinkHandler.parse(url) else { + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { Issue.record("Failed to parse round-trip link") return } @@ -342,7 +342,7 @@ struct ConnectionSharingTests { ) let link = ConnectionExportService.buildImportDeeplink(for: original)! let url = URL(string: link)! - guard case .importConnection(let parsed) = DeeplinkHandler.parse(url) else { + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { Issue.record("Failed to parse round-trip link") return } @@ -373,7 +373,7 @@ struct ConnectionSharingTests { ) let link = ConnectionExportService.buildImportDeeplink(for: original)! let url = URL(string: link)! - guard case .importConnection(let parsed) = DeeplinkHandler.parse(url) else { + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { Issue.record("Failed to parse round-trip link") return } @@ -398,7 +398,7 @@ struct ConnectionSharingTests { ) let link = ConnectionExportService.buildImportDeeplink(for: original)! let url = URL(string: link)! - guard case .importConnection(let parsed) = DeeplinkHandler.parse(url) else { + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { Issue.record("Failed to parse round-trip link") return } @@ -419,7 +419,7 @@ struct ConnectionSharingTests { ) let link = ConnectionExportService.buildImportDeeplink(for: original)! let url = URL(string: link)! - guard case .importConnection(let parsed) = DeeplinkHandler.parse(url) else { + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { Issue.record("Failed to parse round-trip link") return } @@ -435,7 +435,7 @@ struct ConnectionSharingTests { ) let link = ConnectionExportService.buildImportDeeplink(for: original)! let url = URL(string: link)! - guard case .importConnection(let parsed) = DeeplinkHandler.parse(url) else { + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { Issue.record("Failed to parse round-trip link") return } @@ -453,7 +453,7 @@ struct ConnectionSharingTests { ) let link = ConnectionExportService.buildImportDeeplink(for: original)! let url = URL(string: link)! - guard case .importConnection(let parsed) = DeeplinkHandler.parse(url) else { + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { Issue.record("Failed to parse round-trip link") return } @@ -510,7 +510,7 @@ struct ConnectionSharingTests { let link = ConnectionExportService.buildImportDeeplink(for: original)! let url = URL(string: link)! - guard case .importConnection(let parsed) = DeeplinkHandler.parse(url) else { + guard case .success(.importConnection(let parsed)) = DeeplinkParser.parse(url) else { Issue.record("Failed to parse round-trip link") return } diff --git a/TableProTests/Core/Services/DeeplinkHandlerTests.swift b/TableProTests/Core/Services/DeeplinkHandlerTests.swift deleted file mode 100644 index c336353d2..000000000 --- a/TableProTests/Core/Services/DeeplinkHandlerTests.swift +++ /dev/null @@ -1,664 +0,0 @@ -// -// DeeplinkHandlerTests.swift -// TableProTests -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("Deeplink Handler") -@MainActor -struct DeeplinkHandlerTests { - - // MARK: - Connect Actions - - private static let sampleId = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! - - @Test("Connect action with UUID") - func testConnectByUUID() { - let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)")! - let action = DeeplinkHandler.parse(url) - if case .connect(let connectionId) = action { - #expect(connectionId == Self.sampleId) - } else { - Issue.record("Expected .connect, got \(String(describing: action))") - } - } - - @Test("Connect action with non-UUID first segment returns nil") - func testConnectNonUUIDReturnsNil() { - let url = URL(string: "tablepro://connect/Production")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - @Test("Connect action with empty path returns nil") - func testConnectEmptyPathReturnsNil() { - let url = URL(string: "tablepro://connect/")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - @Test("Connect action accepts lowercase UUID") - func testConnectLowercaseUUID() { - let id = UUID() - let url = URL(string: "tablepro://connect/\(id.uuidString.lowercased())")! - if case .connect(let parsed) = DeeplinkHandler.parse(url) { - #expect(parsed == id) - } else { - Issue.record("Expected .connect for lowercase UUID") - } - } - - @Test("Open table without database") - func testOpenTableWithoutDatabase() { - let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)/table/users")! - let action = DeeplinkHandler.parse(url) - if case .openTable(let connectionId, let tableName, let databaseName) = action { - #expect(connectionId == Self.sampleId) - #expect(tableName == "users") - #expect(databaseName == nil) - } else { - Issue.record("Expected .openTable, got \(String(describing: action))") - } - } - - @Test("Open table with database") - func testOpenTableWithDatabase() { - let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)/database/analytics/table/events")! - let action = DeeplinkHandler.parse(url) - if case .openTable(let connectionId, let tableName, let databaseName) = action { - #expect(connectionId == Self.sampleId) - #expect(tableName == "events") - #expect(databaseName == "analytics") - } else { - Issue.record("Expected .openTable, got \(String(describing: action))") - } - } - - @Test("Open query with decoded SQL") - func testOpenQueryDecodedSQL() { - let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)/query?sql=SELECT%20*%20FROM%20users")! - let action = DeeplinkHandler.parse(url) - if case .openQuery(let connectionId, let sql) = action { - #expect(connectionId == Self.sampleId) - #expect(sql == "SELECT * FROM users") - } else { - Issue.record("Expected .openQuery, got \(String(describing: action))") - } - } - - @Test("Open query with empty SQL returns nil") - func testOpenQueryEmptySQLReturnsNil() { - let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)/query?sql=")! - let action = DeeplinkHandler.parse(url) - #expect(action == nil) - } - - @Test("Unrecognized path returns nil") - func testUnrecognizedPathReturnsNil() { - let url = URL(string: "tablepro://connect/\(Self.sampleId.uuidString)/unknown/path")! - let action = DeeplinkHandler.parse(url) - #expect(action == nil) - } - - @Test("Unknown host returns nil") - func testUnknownHostReturnsNil() { - let url = URL(string: "tablepro://unknown-host")! - let action = DeeplinkHandler.parse(url) - #expect(action == nil) - } - - @Test("Wrong scheme returns nil") - func testWrongSchemeReturnsNil() { - let url = URL(string: "https://example.com")! - let action = DeeplinkHandler.parse(url) - #expect(action == nil) - } - - @Test("Malformed UUID with extra characters returns nil") - func testMalformedUUIDReturnsNil() { - let url = URL(string: "tablepro://connect/not-a-real-uuid-1234")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - // MARK: - Integrations Actions - - @Test("Pair action parses required params") - func testPairAction() { - let url = URL(string: "tablepro://integrations/pair?client=Raycast&challenge=abc123&redirect=raycast://callback&scopes=readOnly")! - if case .pairIntegration(let request) = DeeplinkHandler.parse(url) { - #expect(request.clientName == "Raycast") - #expect(request.challenge == "abc123") - #expect(request.redirectURL.absoluteString == "raycast://callback") - #expect(request.requestedScopes == "readOnly") - #expect(request.requestedConnectionIds == nil) - } else { - Issue.record("Expected .pairIntegration") - } - } - - @Test("Pair action parses connection-ids CSV") - func testPairActionConnectionIds() { - let id1 = UUID() - let id2 = UUID() - let csv = "\(id1.uuidString),\(id2.uuidString)" - let url = URL(string: "tablepro://integrations/pair?client=Raycast&challenge=abc&redirect=raycast://cb&connection-ids=\(csv)")! - if case .pairIntegration(let request) = DeeplinkHandler.parse(url) { - #expect(request.requestedConnectionIds == Set([id1, id2])) - } else { - Issue.record("Expected .pairIntegration with parsed UUIDs") - } - } - - @Test("Pair missing client returns nil") - func testPairMissingClientReturnsNil() { - let url = URL(string: "tablepro://integrations/pair?challenge=abc&redirect=raycast://cb")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - @Test("Pair missing challenge returns nil") - func testPairMissingChallengeReturnsNil() { - let url = URL(string: "tablepro://integrations/pair?client=Raycast&redirect=raycast://cb")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - @Test("Exchange action parses code and verifier") - func testExchangeAction() { - let url = URL(string: "tablepro://integrations/exchange?code=abc-123&verifier=xyz-456")! - if case .exchangePairing(let exchange) = DeeplinkHandler.parse(url) { - #expect(exchange.code == "abc-123") - #expect(exchange.verifier == "xyz-456") - } else { - Issue.record("Expected .exchangePairing") - } - } - - @Test("Exchange missing verifier returns nil") - func testExchangeMissingVerifierReturnsNil() { - let url = URL(string: "tablepro://integrations/exchange?code=abc")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - @Test("Start MCP action parses without params") - func testStartMCPAction() { - let url = URL(string: "tablepro://integrations/start-mcp")! - if case .startMCP = DeeplinkHandler.parse(url) { - // matched - } else { - Issue.record("Expected .startMCP") - } - } - - @Test("Unknown integrations action returns nil") - func testUnknownIntegrationsAction() { - let url = URL(string: "tablepro://integrations/unknown")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - // MARK: - Import — Basic Fields - - @Test("Import with all basic params") - func testImportBasicParams() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&port=3306&username=root&database=mydb")! - let action = DeeplinkHandler.parse(url) - guard case .importConnection(let conn) = action else { - Issue.record("Expected .importConnection, got \(String(describing: action))") - return - } - #expect(conn.name == "Dev") - #expect(conn.host == "localhost") - #expect(conn.port == 3306) - #expect(conn.type == "MySQL") - #expect(conn.username == "root") - #expect(conn.database == "mydb") - } - - @Test("Import with minimal required params") - func testImportMinimalParams() { - let url = URL(string: "tablepro://import?name=Test&host=db.example.com&type=postgresql")! - let action = DeeplinkHandler.parse(url) - guard case .importConnection(let conn) = action else { - Issue.record("Expected .importConnection, got \(String(describing: action))") - return - } - #expect(conn.name == "Test") - #expect(conn.host == "db.example.com") - #expect(conn.type == "PostgreSQL") - #expect(conn.username == "") - #expect(conn.database == "") - #expect(conn.sshConfig == nil) - #expect(conn.sslConfig == nil) - #expect(conn.color == nil) - #expect(conn.tagName == nil) - #expect(conn.groupName == nil) - #expect(conn.additionalFields == nil) - } - - @Test("Import uses default port when not specified") - func testImportDefaultPort() { - let url = URL(string: "tablepro://import?name=PG&host=localhost&type=postgresql")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.port == 5432) - } - - @Test("Import with case-insensitive type") - func testImportCaseInsensitiveType() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=PostgreSQL")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.type == "PostgreSQL") - } - - @Test("Import missing name returns nil") - func testImportMissingNameReturnsNil() { - let url = URL(string: "tablepro://import?host=localhost&type=mysql")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - @Test("Import missing host returns nil") - func testImportMissingHostReturnsNil() { - let url = URL(string: "tablepro://import?name=Dev&type=mysql")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - @Test("Import missing type returns nil") - func testImportMissingTypeReturnsNil() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - @Test("Import with empty name returns nil") - func testImportEmptyNameReturnsNil() { - let url = URL(string: "tablepro://import?name=&host=localhost&type=mysql")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - @Test("Import with empty host returns nil") - func testImportEmptyHostReturnsNil() { - let url = URL(string: "tablepro://import?name=Dev&host=&type=mysql")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - // MARK: - Import — SSH Config - - @Test("Import with SSH config") - func testImportWithSSH() { - let url = URL(string: "tablepro://import?name=Prod&host=db.internal&type=postgresql&ssh=1&sshHost=bastion.example.com&sshPort=2222&sshUsername=deploy&sshAuthMethod=privateKey&sshPrivateKeyPath=~/.ssh/id_ed25519")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sshConfig != nil) - #expect(conn.sshConfig?.enabled == true) - #expect(conn.sshConfig?.host == "bastion.example.com") - #expect(conn.sshConfig?.port == 2222) - #expect(conn.sshConfig?.username == "deploy") - #expect(conn.sshConfig?.authMethod == "privateKey") - #expect(conn.sshConfig?.privateKeyPath == "~/.ssh/id_ed25519") - } - - @Test("Import without ssh=1 has no SSH config") - func testImportNoSSHFlag() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&sshHost=bastion.com")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sshConfig == nil) - } - - @Test("Import with SSH defaults") - func testImportSSHDefaults() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&ssh=1&sshHost=bastion.com")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sshConfig?.port == 22) - #expect(conn.sshConfig?.username == "") - #expect(conn.sshConfig?.authMethod == "password") - #expect(conn.sshConfig?.privateKeyPath == "") - #expect(conn.sshConfig?.useSSHConfig == false) - #expect(conn.sshConfig?.agentSocketPath == "") - } - - @Test("Import with SSH agent") - func testImportSSHAgent() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&ssh=1&sshHost=bastion.com&sshAuthMethod=sshAgent&sshAgentSocketPath=/tmp/agent.sock")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sshConfig?.authMethod == "sshAgent") - #expect(conn.sshConfig?.agentSocketPath == "/tmp/agent.sock") - } - - @Test("Import with SSH use config flag") - func testImportSSHUseConfig() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&ssh=1&sshHost=bastion.com&sshUseSSHConfig=1")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sshConfig?.useSSHConfig == true) - } - - @Test("Import with SSH TOTP config") - func testImportSSHTOTP() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&ssh=1&sshHost=bastion.com&sshTotpMode=autoGenerate&sshTotpAlgorithm=sha256&sshTotpDigits=8&sshTotpPeriod=60")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sshConfig?.totpMode == "autoGenerate") - #expect(conn.sshConfig?.totpAlgorithm == "sha256") - #expect(conn.sshConfig?.totpDigits == 8) - #expect(conn.sshConfig?.totpPeriod == 60) - } - - @Test("Import with SSH jump hosts") - func testImportSSHJumpHosts() { - let jumpJson = #"[{"host":"jump1.com","port":22,"username":"admin","authMethod":"password","privateKeyPath":""}]"# - let encoded = jumpJson.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&ssh=1&sshHost=bastion.com&sshJumpHosts=\(encoded)")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sshConfig?.jumpHosts?.count == 1) - #expect(conn.sshConfig?.jumpHosts?.first?.host == "jump1.com") - #expect(conn.sshConfig?.jumpHosts?.first?.username == "admin") - } - - @Test("Import with invalid jump hosts JSON ignores gracefully") - func testImportInvalidJumpHostsJSON() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&ssh=1&sshHost=bastion.com&sshJumpHosts=not-json")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sshConfig?.jumpHosts == nil) - } - - // MARK: - Import — SSL Config - - @Test("Import with SSL config") - func testImportWithSSL() { - let url = URL(string: "tablepro://import?name=Prod&host=db.com&type=postgresql&sslMode=require&sslCaCertPath=~/certs/ca.pem&sslClientCertPath=~/certs/client.pem&sslClientKeyPath=~/certs/client.key")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sslConfig != nil) - #expect(conn.sslConfig?.mode == "require") - #expect(conn.sslConfig?.caCertificatePath == "~/certs/ca.pem") - #expect(conn.sslConfig?.clientCertificatePath == "~/certs/client.pem") - #expect(conn.sslConfig?.clientKeyPath == "~/certs/client.key") - } - - @Test("Import without sslMode has no SSL config") - func testImportNoSSLMode() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&sslCaCertPath=~/ca.pem")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sslConfig == nil) - } - - @Test("Import with SSL mode only, no cert paths") - func testImportSSLModeOnly() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&sslMode=preferred")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sslConfig?.mode == "preferred") - #expect(conn.sslConfig?.caCertificatePath == nil) - } - - // MARK: - Import — Metadata - - @Test("Import with color") - func testImportWithColor() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&color=red")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.color == "red") - } - - @Test("Import with tag and group names") - func testImportWithTagAndGroup() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&tagName=production&groupName=Backend%20Services")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.tagName == "production") - #expect(conn.groupName == "Backend Services") - } - - @Test("Import with safe mode level") - func testImportWithSafeModeLevel() { - let url = URL(string: "tablepro://import?name=Prod&host=db.com&type=postgresql&safeModeLevel=readOnly")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.safeModeLevel == "readOnly") - } - - @Test("Import with AI policy") - func testImportWithAIPolicy() { - let url = URL(string: "tablepro://import?name=Prod&host=db.com&type=postgresql&aiPolicy=never")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.aiPolicy == "never") - } - - // MARK: - Import — Other Fields - - @Test("Import with Redis database") - func testImportWithRedisDatabase() { - let url = URL(string: "tablepro://import?name=Cache&host=localhost&type=redis&redisDatabase=3")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.redisDatabase == 3) - } - - @Test("Import with startup commands") - func testImportWithStartupCommands() { - let commands = "SET search_path TO myschema;" - let encoded = commands.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=postgresql&startupCommands=\(encoded)")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.startupCommands == commands) - } - - @Test("Import with localOnly flag") - func testImportWithLocalOnly() { - let url = URL(string: "tablepro://import?name=Local&host=localhost&type=sqlite&localOnly=1")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.localOnly == true) - } - - @Test("Import without localOnly defaults to nil") - func testImportLocalOnlyDefault() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.localOnly == nil) - } - - // MARK: - Import — Additional Fields (Plugin) - - @Test("Import with additional fields using af_ prefix") - func testImportAdditionalFields() { - let url = URL(string: "tablepro://import?name=Mongo&host=cluster.mongodb.net&type=mongodb&af_authSource=admin&af_replicaSet=rs0&af_useSrv=true")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.additionalFields?["authSource"] == "admin") - #expect(conn.additionalFields?["replicaSet"] == "rs0") - #expect(conn.additionalFields?["useSrv"] == "true") - } - - @Test("Import with af_ prefix but no value is ignored") - func testImportAdditionalFieldsNoValue() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&af_emptyField=")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.additionalFields == nil) - } - - @Test("Import with af_ prefix but empty key is ignored") - func testImportAdditionalFieldsEmptyKey() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&af_=someValue")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.additionalFields == nil) - } - - // MARK: - Import — Combined Full Config - - @Test("Import with all fields combined") - func testImportFullConfig() { - var components = URLComponents() - components.scheme = "tablepro" - components.host = "import" - components.queryItems = [ - URLQueryItem(name: "name", value: "Production DB"), - URLQueryItem(name: "host", value: "db.prod.internal"), - URLQueryItem(name: "port", value: "5433"), - URLQueryItem(name: "type", value: "postgresql"), - URLQueryItem(name: "username", value: "app_user"), - URLQueryItem(name: "database", value: "main"), - URLQueryItem(name: "ssh", value: "1"), - URLQueryItem(name: "sshHost", value: "bastion.prod.com"), - URLQueryItem(name: "sshPort", value: "2222"), - URLQueryItem(name: "sshUsername", value: "deploy"), - URLQueryItem(name: "sshAuthMethod", value: "privateKey"), - URLQueryItem(name: "sshPrivateKeyPath", value: "~/.ssh/prod_key"), - URLQueryItem(name: "sslMode", value: "verify-ca"), - URLQueryItem(name: "sslCaCertPath", value: "~/certs/ca.pem"), - URLQueryItem(name: "color", value: "red"), - URLQueryItem(name: "tagName", value: "production"), - URLQueryItem(name: "groupName", value: "Backend"), - URLQueryItem(name: "safeModeLevel", value: "readOnly"), - URLQueryItem(name: "aiPolicy", value: "never"), - URLQueryItem(name: "startupCommands", value: "SET statement_timeout = 30000;"), - URLQueryItem(name: "af_schema", value: "public"), - ] - let url = components.url! - - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - - #expect(conn.name == "Production DB") - #expect(conn.host == "db.prod.internal") - #expect(conn.port == 5433) - #expect(conn.type == "PostgreSQL") - #expect(conn.username == "app_user") - #expect(conn.database == "main") - - #expect(conn.sshConfig?.enabled == true) - #expect(conn.sshConfig?.host == "bastion.prod.com") - #expect(conn.sshConfig?.port == 2222) - #expect(conn.sshConfig?.username == "deploy") - #expect(conn.sshConfig?.authMethod == "privateKey") - #expect(conn.sshConfig?.privateKeyPath == "~/.ssh/prod_key") - - #expect(conn.sslConfig?.mode == "verify-ca") - #expect(conn.sslConfig?.caCertificatePath == "~/certs/ca.pem") - - #expect(conn.color == "red") - #expect(conn.tagName == "production") - #expect(conn.groupName == "Backend") - #expect(conn.safeModeLevel == "readOnly") - #expect(conn.aiPolicy == "never") - #expect(conn.startupCommands == "SET statement_timeout = 30000;") - #expect(conn.additionalFields?["schema"] == "public") - } - - // MARK: - Import — Security - - @Test("Import link never contains password field") - func testImportNoPasswordField() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&password=secret123")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.name == "Dev") - } - - // MARK: - Import — Edge Cases - - @Test("Import with percent-encoded special characters in name") - func testImportSpecialCharsInName() { - let url = URL(string: "tablepro://import?name=Dev%20%26%20Staging&host=localhost&type=mysql")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.name == "Dev & Staging") - } - - @Test("Import with IPv6 host") - func testImportIPv6Host() { - let url = URL(string: "tablepro://import?name=IPv6&host=::1&type=postgresql")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.host == "::1") - } - - @Test("Import with no query params returns nil") - func testImportNoQueryParams() { - let url = URL(string: "tablepro://import")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - @Test("Import with unknown type returns nil") - func testImportUnknownType() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=nonexistent_db")! - #expect(DeeplinkHandler.parse(url) == nil) - } - - @Test("sshProfileId is always nil in deep links") - func testImportSSHProfileIdAlwaysNil() { - let url = URL(string: "tablepro://import?name=Dev&host=localhost&type=mysql&ssh=1&sshHost=bastion.com")! - guard case .importConnection(let conn) = DeeplinkHandler.parse(url) else { - Issue.record("Expected .importConnection") - return - } - #expect(conn.sshProfileId == nil) - } -} diff --git a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift index 7d267b654..47b290847 100644 --- a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift +++ b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift @@ -103,30 +103,6 @@ struct TabPersistenceCoordinatorTests { await sleep() } - @Test("saveNow with pre-converted PersistedTab array round-trips") - func saveNowWithPersistedTabsRoundTrips() async { - let coordinator = makeCoordinator() - let persistedTabs = [ - PersistedTab(id: UUID(), title: "P1", query: "SELECT 1", tabType: .query, tableName: nil), - PersistedTab(id: UUID(), title: "P2", query: "SELECT 2", tabType: .table, tableName: "users") - ] - let selectedId = persistedTabs[0].id - - coordinator.saveNow(persistedTabs: persistedTabs, selectedTabId: selectedId) - await sleep() - - let result = await coordinator.restoreFromDisk() - - #expect(result.tabs.count == 2) - #expect(result.selectedTabId == selectedId) - #expect(result.tabs[0].title == "P1") - #expect(result.tabs[1].tableContext.tableName == "users") - #expect(result.source == .disk) - - coordinator.clearSavedState() - await sleep() - } - @Test("Large query over 500KB is truncated to empty string in persisted tab") func largeQueryIsTruncated() async { let coordinator = makeCoordinator() diff --git a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift index 9d369d289..ff6699b2b 100644 --- a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift @@ -17,11 +17,12 @@ struct TableQueryBuilderMSSQLTests { init() { FakeMSSQLPluginRegistration.registerIfNeeded() let dialect = PluginManager.shared.sqlDialect(for: .mssql) + let dialectQuote = dialect.map(quoteIdentifierFromDialect) self.builder = TableQueryBuilder( databaseType: .mssql, pluginDriver: PluginManager.shared.queryBuildingDriver(for: .mssql), dialect: dialect, - dialectQuote: quoteIdentifierFromDialect(dialect) + dialectQuote: dialectQuote ) } diff --git a/TableProTests/Core/Services/WelcomeWindowSuppressionTests.swift b/TableProTests/Core/Services/WelcomeWindowSuppressionTests.swift deleted file mode 100644 index 513cc8a7f..000000000 --- a/TableProTests/Core/Services/WelcomeWindowSuppressionTests.swift +++ /dev/null @@ -1,286 +0,0 @@ -// -// WelcomeWindowSuppressionTests.swift -// TableProTests -// -// Regression tests for the welcome window suppression logic in AppDelegate+WindowConfig. -// Covers the fix where double-clicking .duckdb files from Finder caused the app to freeze -// because suppression gave up too early and welcome was closed instead of hidden. -// - -import AppKit -import Foundation -import Testing -@testable import TablePro - -@Suite("Welcome Window Suppression") -@MainActor -struct WelcomeWindowSuppressionTests { - /// Create a fresh AppDelegate for each test — avoids relying on NSApp.delegate - /// which may not be our AppDelegate in parallel test runner processes. - private func makeAppDelegate() -> AppDelegate { - AppDelegate() - } - - private func makeWindow(identifier: String) -> NSWindow { - let window = NSWindow() - window.identifier = NSUserInterfaceItemIdentifier(identifier) - return window - } - - // MARK: - Window Identification - - @Test("isMainWindow — exact identifier 'main'") - func isMainWindowExact() { - let delegate = makeAppDelegate() - let window = makeWindow(identifier: "main") - #expect(delegate.isMainWindow(window)) - } - - @Test("isMainWindow — prefixed identifier 'main-123'") - func isMainWindowPrefixed() { - let delegate = makeAppDelegate() - let window = makeWindow(identifier: "main-123") - #expect(delegate.isMainWindow(window)) - } - - @Test("isMainWindow — returns false for nil identifier") - func isMainWindowNilIdentifier() { - let delegate = makeAppDelegate() - let window = NSWindow() - window.identifier = nil - #expect(!delegate.isMainWindow(window)) - } - - @Test("isMainWindow — returns false for 'welcome'") - func isMainWindowUnrelated() { - let delegate = makeAppDelegate() - let window = makeWindow(identifier: "welcome") - #expect(!delegate.isMainWindow(window)) - } - - @Test("isMainWindow — returns false for 'mainExtra' (no dash separator)") - func isMainWindowNoDash() { - let delegate = makeAppDelegate() - let window = makeWindow(identifier: "mainExtra") - #expect(!delegate.isMainWindow(window)) - } - - @Test("isWelcomeWindow — exact identifier 'welcome'") - func isWelcomeWindowExact() { - let delegate = makeAppDelegate() - let window = makeWindow(identifier: "welcome") - #expect(delegate.isWelcomeWindow(window)) - } - - @Test("isWelcomeWindow — prefixed identifier 'welcome-abc'") - func isWelcomeWindowPrefixed() { - let delegate = makeAppDelegate() - let window = makeWindow(identifier: "welcome-abc") - #expect(delegate.isWelcomeWindow(window)) - } - - @Test("isWelcomeWindow — returns false for nil identifier") - func isWelcomeWindowNilIdentifier() { - let delegate = makeAppDelegate() - let window = NSWindow() - window.identifier = nil - #expect(!delegate.isWelcomeWindow(window)) - } - - @Test("isWelcomeWindow — returns false for 'main'") - func isWelcomeWindowNotMain() { - let delegate = makeAppDelegate() - let window = makeWindow(identifier: "main") - #expect(!delegate.isWelcomeWindow(window)) - } - - @Test("isWelcomeWindow — returns false for 'welcomeExtra' (no dash separator)") - func isWelcomeWindowNoDash() { - let delegate = makeAppDelegate() - let window = makeWindow(identifier: "welcomeExtra") - #expect(!delegate.isWelcomeWindow(window)) - } - - // MARK: - suppressWelcomeWindow State - - @Test("suppressWelcomeWindow — sets isHandlingFileOpen to true") - func suppressSetsFlag() { - let delegate = makeAppDelegate() - delegate.suppressWelcomeWindow() - #expect(delegate.isHandlingFileOpen == true) - } - - @Test("suppressWelcomeWindow — increments fileOpenSuppressionCount") - func suppressIncrementsCount() { - let delegate = makeAppDelegate() - delegate.suppressWelcomeWindow() - #expect(delegate.fileOpenSuppressionCount == 1) - - delegate.suppressWelcomeWindow() - #expect(delegate.fileOpenSuppressionCount == 2) - } - - @Test("suppressWelcomeWindow — hides existing welcome windows via orderOut") - func suppressHidesWelcomeWindows() { - let delegate = makeAppDelegate() - - let welcome = makeWindow(identifier: "welcome") - welcome.orderFront(nil) - defer { welcome.close() } - - #expect(welcome.isVisible) - - delegate.suppressWelcomeWindow() - - #expect(!welcome.isVisible) - } - - // MARK: - windowDidBecomeKey Suppression Behavior - - @Test("windowDidBecomeKey — welcome hides (orderOut) when file open and no main window") - func windowDidBecomeKeyHidesWelcomeWhenNoMain() { - let delegate = makeAppDelegate() - delegate.isHandlingFileOpen = true - - let welcome = makeWindow(identifier: "welcome") - welcome.orderFront(nil) - defer { welcome.close() } - - #expect(welcome.isVisible) - - let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: welcome) - delegate.windowDidBecomeKey(notification) - - // Key regression fix: welcome should be hidden (not closed) so it can reappear - // when the main window is ready — prevents "no visible windows" freeze - #expect(!welcome.isVisible) - } - - @Test("windowDidBecomeKey — welcome closes when file open and main window exists") - func windowDidBecomeKeyClosesWelcomeWhenMainExists() { - let delegate = makeAppDelegate() - delegate.isHandlingFileOpen = true - - let mainWin = makeWindow(identifier: "main") - mainWin.orderFront(nil) - defer { mainWin.close() } - - let welcome = makeWindow(identifier: "welcome") - welcome.orderFront(nil) - defer { welcome.close() } - - let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: welcome) - delegate.windowDidBecomeKey(notification) - - #expect(!welcome.isVisible) - } - - @Test("windowDidBecomeKey — welcome not suppressed when isHandlingFileOpen is false") - func windowDidBecomeKeyNoSuppressionWhenNotHandlingFile() { - let delegate = makeAppDelegate() - delegate.isHandlingFileOpen = false - - let welcome = makeWindow(identifier: "welcome") - welcome.orderFront(nil) - defer { welcome.close() } - - let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: welcome) - delegate.windowDidBecomeKey(notification) - - #expect(welcome.isVisible) - } - - @Test("windowDidBecomeKey — non-welcome window is not affected by suppression") - func windowDidBecomeKeyIgnoresNonWelcome() { - let delegate = makeAppDelegate() - delegate.isHandlingFileOpen = true - - let other = makeWindow(identifier: "settings") - other.orderFront(nil) - defer { other.close() } - - let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: other) - delegate.windowDidBecomeKey(notification) - - #expect(other.isVisible) - } - - // MARK: - Suppression Count State - - @Test("Multiple suppress calls — count increments independently") - func multipleSuppressionCountsStack() { - let delegate = makeAppDelegate() - delegate.suppressWelcomeWindow() - delegate.suppressWelcomeWindow() - delegate.suppressWelcomeWindow() - - #expect(delegate.fileOpenSuppressionCount == 3) - #expect(delegate.isHandlingFileOpen == true) - } - - @Test("endFileOpenSuppression — decrement to zero resets isHandlingFileOpen") - func endSuppressionResetsFlag() { - let delegate = makeAppDelegate() - delegate.isHandlingFileOpen = true - delegate.fileOpenSuppressionCount = 1 - - delegate.endFileOpenSuppression() - - #expect(delegate.fileOpenSuppressionCount == 0) - #expect(delegate.isHandlingFileOpen == false) - } - - @Test("endFileOpenSuppression — keeps flag true while count > 0") - func endSuppressionKeepsFlagWhilePositive() { - let delegate = makeAppDelegate() - delegate.isHandlingFileOpen = true - delegate.fileOpenSuppressionCount = 2 - - delegate.endFileOpenSuppression() - - #expect(delegate.fileOpenSuppressionCount == 1) - #expect(delegate.isHandlingFileOpen == true) - } - - // MARK: - Main Window Becomes Key - - @Test("windowDidBecomeKey — main window appearing closes welcome during file open") - func windowDidBecomeKeyMainWindowClosesWelcome() { - let delegate = makeAppDelegate() - delegate.isHandlingFileOpen = true - - let welcome = makeWindow(identifier: "welcome") - welcome.orderFront(nil) - defer { welcome.close() } - - let mainWin = makeWindow(identifier: "main") - mainWin.orderFront(nil) - defer { mainWin.close() } - - // Simulate main window becoming key — should close welcome - let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: mainWin) - delegate.windowDidBecomeKey(notification) - - #expect(!welcome.isVisible) - } - - @Test("windowDidBecomeKey — main window does not close welcome when not handling file open") - func windowDidBecomeKeyMainWindowNoEffectWhenNotHandling() { - let delegate = makeAppDelegate() - delegate.isHandlingFileOpen = false - - let welcome = makeWindow(identifier: "welcome") - welcome.orderFront(nil) - defer { welcome.close() } - - let mainWin = makeWindow(identifier: "main") - mainWin.orderFront(nil) - defer { mainWin.close() } - - let notification = Notification(name: NSWindow.didBecomeKeyNotification, object: mainWin) - delegate.windowDidBecomeKey(notification) - - // Welcome should remain visible — no suppression active - #expect(welcome.isVisible) - } -} diff --git a/TableProTests/ViewModels/SidebarViewModelTests.swift b/TableProTests/ViewModels/SidebarViewModelTests.swift index 90b0e9e61..f153a07c0 100644 --- a/TableProTests/ViewModels/SidebarViewModelTests.swift +++ b/TableProTests/ViewModels/SidebarViewModelTests.swift @@ -42,7 +42,6 @@ private func makeSUT( let optionsBinding = Binding(get: { optionsState }, set: { optionsState = $0 }) let vm = SidebarViewModel( - tables: tablesBinding, selectedTables: selectedBinding, pendingTruncates: truncatesBinding, pendingDeletes: deletesBinding, diff --git a/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift b/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift deleted file mode 100644 index f92afe6e5..000000000 --- a/TableProTests/Views/Main/CoordinatorReloadSidebarTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// CoordinatorRefreshTablesTests.swift -// TableProTests -// -// Tests for MainContentCoordinator.refreshTables() — -// verifies it updates sidebarLoadingState and populates session tables. -// - -import SwiftUI -import Testing - -@testable import TablePro - -@Suite("CoordinatorRefreshTables") -struct CoordinatorRefreshTablesTests { - @Test("refreshTables sets loading state to error when no driver") - @MainActor - func setsErrorWhenNoDriver() async { - let connection = TestFixtures.makeConnection(database: "db_a") - let tabManager = QueryTabManager() - let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() - let toolbarState = ConnectionToolbarState() - - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), - toolbarState: toolbarState - ) - defer { coordinator.teardown() } - - #expect(coordinator.sidebarLoadingState == .idle) - - await coordinator.refreshTables() - - #expect(coordinator.sidebarLoadingState == .error("Not connected")) - } - - @Test("sidebarLoadingState defaults to idle") - @MainActor - func defaultsToIdle() { - let connection = TestFixtures.makeConnection(database: "db_a") - let tabManager = QueryTabManager() - let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() - let toolbarState = ConnectionToolbarState() - - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), - toolbarState: toolbarState - ) - defer { coordinator.teardown() } - - #expect(coordinator.sidebarLoadingState == .idle) - } -} diff --git a/TableProTests/Views/Main/EvictionTests.swift b/TableProTests/Views/Main/EvictionTests.swift index 5bb20c441..deab387d0 100644 --- a/TableProTests/Views/Main/EvictionTests.swift +++ b/TableProTests/Views/Main/EvictionTests.swift @@ -33,7 +33,7 @@ struct EvictionTests { to coordinator: MainContentCoordinator, tabManager: QueryTabManager, tableName: String = "users" - ) { + ) throws { try tabManager.addTableTab(tableName: tableName) guard let index = tabManager.selectedTabIndex else { return } let rows = TestFixtures.makeRows(count: 10) @@ -46,12 +46,11 @@ struct EvictionTests { } @Test("evictInactiveRowData evicts background tabs without pending changes") - func evictsLoadedTabs() { + func evictsLoadedTabs() throws { let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") + try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") let backgroundTabId = tabManager.tabs[0].id - // Add a second tab so the first becomes background (eviction skips the selected tab) - addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") + try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") #expect(coordinator.tableRowsStore.tableRows(for: backgroundTabId).rows.count == 10) #expect(coordinator.tableRowsStore.isEvicted(backgroundTabId) == false) @@ -63,9 +62,9 @@ struct EvictionTests { } @Test("evictInactiveRowData skips tabs with pending changes") - func skipsTabsWithPendingChanges() { + func skipsTabsWithPendingChanges() throws { let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") + try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") tabManager.tabs[0].pendingChanges.deletedRowIndices = [0] @@ -77,11 +76,11 @@ struct EvictionTests { } @Test("evictInactiveRowData preserves column metadata after eviction") - func preservesMetadataAfterEviction() { + func preservesMetadataAfterEviction() throws { let (coordinator, tabManager) = makeCoordinator() - addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") + try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") let backgroundTabId = tabManager.tabs[0].id - addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") + try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") coordinator.evictInactiveRowData() diff --git a/TableProTests/Views/Main/RowOperationsDispatchTests.swift b/TableProTests/Views/Main/RowOperationsDispatchTests.swift index 17d938e4c..7bd73ce8c 100644 --- a/TableProTests/Views/Main/RowOperationsDispatchTests.swift +++ b/TableProTests/Views/Main/RowOperationsDispatchTests.swift @@ -49,7 +49,7 @@ struct RowOperationsDispatchTests { let tabId: UUID } - private func makeFixture(rowCount: Int = 5) -> Fixture { + private func makeFixture(rowCount: Int = 5) throws -> Fixture { let tabManager = QueryTabManager() let coordinator = MainContentCoordinator( connection: TestFixtures.makeConnection(), @@ -87,8 +87,8 @@ struct RowOperationsDispatchTests { } @Test("Soft-delete of existing rows dispatches invalidateCachesForUndoRedo") - func softDeleteDispatchesInvalidate() { - let f = makeFixture(rowCount: 5) + func softDeleteDispatchesInvalidate() throws { + let f = try makeFixture(rowCount: 5) let beforeInvalidate = f.fake.invalidateCount f.coordinator.deleteSelectedRows(indices: [0, 1]) @@ -98,8 +98,8 @@ struct RowOperationsDispatchTests { } @Test("Physical delete of inserted rows dispatches applyDelta, not invalidate") - func physicalDeleteDispatchesDelta() { - let f = makeFixture(rowCount: 3) + func physicalDeleteDispatchesDelta() throws { + let f = try makeFixture(rowCount: 3) f.coordinator.addNewRow() let insertedIndex = f.coordinator.tableRowsStore.tableRows(for: f.tabId).count - 1 let beforeInvalidate = f.fake.invalidateCount diff --git a/TableProTests/Views/Main/SortCacheInvalidationTests.swift b/TableProTests/Views/Main/SortCacheInvalidationTests.swift index 955f3e104..4c33a997a 100644 --- a/TableProTests/Views/Main/SortCacheInvalidationTests.swift +++ b/TableProTests/Views/Main/SortCacheInvalidationTests.swift @@ -16,7 +16,7 @@ import Testing @Suite("querySortCache invalidation on row mutations") @MainActor struct SortCacheInvalidationTests { - private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager, UUID) { + private func makeCoordinator() throws -> (MainContentCoordinator, QueryTabManager, UUID) { let tabManager = QueryTabManager() let coordinator = MainContentCoordinator( connection: TestFixtures.makeConnection(), @@ -51,8 +51,8 @@ struct SortCacheInvalidationTests { } @Test("addNewRow clears querySortCache for the tab") - func addNewRowInvalidatesCache() { - let (coordinator, _, tabId) = makeCoordinator() + func addNewRowInvalidatesCache() throws { + let (coordinator, _, tabId) = try makeCoordinator() seedRows(coordinator, for: tabId, count: 3) seedCache(coordinator, for: tabId) @@ -62,8 +62,8 @@ struct SortCacheInvalidationTests { } @Test("deleteSelectedRows clears querySortCache when physically removing inserted rows") - func physicalDeleteInvalidatesCache() { - let (coordinator, _, tabId) = makeCoordinator() + func physicalDeleteInvalidatesCache() throws { + let (coordinator, _, tabId) = try makeCoordinator() seedRows(coordinator, for: tabId, count: 3) coordinator.addNewRow() let insertedIndex = coordinator.tableRowsStore.tableRows(for: tabId).count - 1 @@ -75,8 +75,8 @@ struct SortCacheInvalidationTests { } @Test("deleteSelectedRows preserves querySortCache on soft delete of existing rows") - func softDeletePreservesCache() { - let (coordinator, _, tabId) = makeCoordinator() + func softDeletePreservesCache() throws { + let (coordinator, _, tabId) = try makeCoordinator() seedRows(coordinator, for: tabId, count: 5) seedCache(coordinator, for: tabId) @@ -86,8 +86,8 @@ struct SortCacheInvalidationTests { } @Test("duplicateSelectedRow clears querySortCache for the tab") - func duplicateRowInvalidatesCache() { - let (coordinator, _, tabId) = makeCoordinator() + func duplicateRowInvalidatesCache() throws { + let (coordinator, _, tabId) = try makeCoordinator() seedRows(coordinator, for: tabId, count: 3) seedCache(coordinator, for: tabId) diff --git a/TableProTests/Views/SwitchDatabaseTests.swift b/TableProTests/Views/SwitchDatabaseTests.swift index e5e8c6f87..da85d8807 100644 --- a/TableProTests/Views/SwitchDatabaseTests.swift +++ b/TableProTests/Views/SwitchDatabaseTests.swift @@ -27,92 +27,6 @@ private func simulateDatabaseSwitch( @Suite("SwitchDatabase") struct SwitchDatabaseTests { - // MARK: - sidebarLoadingState - - @Test("sidebarLoadingState defaults to idle") - @MainActor - func loadingStateDefaultsToIdle() { - let connection = TestFixtures.makeConnection() - let tabManager = QueryTabManager() - let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() - let toolbarState = ConnectionToolbarState() - - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), - toolbarState: toolbarState - ) - defer { coordinator.teardown() } - - #expect(coordinator.sidebarLoadingState == .idle) - } - - // MARK: - openTableTab behavior during database switch - - @Test("openTableTab skips new window when sidebar is loading with existing tabs") - @MainActor - func openTableTabSkipsNewWindowDuringSwitch() throws { - let connection = TestFixtures.makeConnection(database: "db_a") - let tabManager = QueryTabManager() - let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() - let toolbarState = ConnectionToolbarState() - - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), - toolbarState: toolbarState - ) - defer { coordinator.teardown() } - - try tabManager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "db_a") - let tabCountBefore = tabManager.tabs.count - - coordinator.sidebarLoadingState = .loading - - coordinator.openTableTab("orders") - - #expect(tabManager.tabs.count == tabCountBefore) - } - - @Test("openTableTab adds tab in-place when sidebar is loading with empty tabs") - @MainActor - func openTableTabAddsInPlaceWhenSwitchingWithEmptyTabs() { - let connection = TestFixtures.makeConnection(database: "db_a") - let tabManager = QueryTabManager() - let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() - let toolbarState = ConnectionToolbarState() - - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), - toolbarState: toolbarState - ) - defer { coordinator.teardown() } - - #expect(tabManager.tabs.isEmpty) - - coordinator.sidebarLoadingState = .loading - - coordinator.openTableTab("users") - - #expect(tabManager.tabs.count == 1) - #expect(tabManager.tabs.first?.tableContext.tableName == "users") - } - - // MARK: - openTableTab fast path (same table + same database) - @Test("openTableTab skips when table is already active tab in same database") @MainActor func openTableTabSkipsForSameTableSameDatabase() throws { @@ -183,33 +97,4 @@ struct SwitchDatabaseTests { #expect(tabManager.selectedTabId == nil) } - // MARK: - sidebarLoadingState during database switch - - @Test("switchDatabase sets sidebarLoadingState to loading then error when no driver") - @MainActor - func switchDatabaseSetsLoadingState() async { - let connection = TestFixtures.makeConnection(database: "db_a") - let tabManager = QueryTabManager() - let changeManager = DataChangeManager() - let filterStateManager = FilterStateManager() - let toolbarState = ConnectionToolbarState() - - let coordinator = MainContentCoordinator( - connection: connection, - tabManager: tabManager, - changeManager: changeManager, - filterStateManager: filterStateManager, - columnVisibilityManager: ColumnVisibilityManager(), - toolbarState: toolbarState - ) - defer { coordinator.teardown() } - - #expect(coordinator.sidebarLoadingState == .idle) - - await coordinator.switchDatabase(to: "db_b") - - // Without a driver, switchDatabase sets loading then returns early - // refreshTables will set error state since there's no driver - #expect(coordinator.sidebarLoadingState == .error("Not connected")) - } } From 0d93d4e7f690ae6a38d74c525487401b70206094 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 17:31:54 +0700 Subject: [PATCH 06/54] fix(mcp): make MCPHttpServerError messages human-readable Conform to LocalizedError so error.localizedDescription returns the actual reason (e.g., 'Remote access requires TLS to be enabled') instead of the opaque NSError-bridged 'MCPHttpServerError error 0'. Also log the configuration shape on start so failures show bindAddress, port, and tls status. --- .../Core/MCP/Transport/MCPHttpServerError.swift | 17 ++++++++++++++++- .../MCP/Transport/MCPHttpServerTransport.swift | 4 ++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerError.swift b/TablePro/Core/MCP/Transport/MCPHttpServerError.swift index 7c00da248..960139db9 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerError.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerError.swift @@ -1,9 +1,24 @@ import Foundation -public enum MCPHttpServerError: Error, Sendable, Equatable { +public enum MCPHttpServerError: Error, Sendable, Equatable, LocalizedError { case tlsRequiredForRemoteAccess case alreadyStarted case notStarted case bindFailed(reason: String) case acceptCancelled + + public var errorDescription: String? { + switch self { + case .tlsRequiredForRemoteAccess: + return "Remote access requires TLS to be enabled" + case .alreadyStarted: + return "MCP server is already running" + case .notStarted: + return "MCP server is not running" + case .bindFailed(let reason): + return "Failed to bind MCP server: \(reason)" + case .acceptCancelled: + return "MCP server accept loop was cancelled" + } + } } diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift index d0c9bdc97..51d85890a 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -66,10 +66,14 @@ public actor MCPHttpServerTransport { public func start() async throws { guard listener == nil else { + Self.logger.warning("start() called while listener already exists") throw MCPHttpServerError.alreadyStarted } + Self.logger.info("Starting MCP HTTP server: bind=\(String(describing: self.configuration.bindAddress)) port=\(self.configuration.port) tls=\(self.configuration.tls != nil)") + if configuration.bindAddress == .anyInterface, configuration.tls == nil { + Self.logger.error("Remote access requested without TLS — refusing to start") throw MCPHttpServerError.tlsRequiredForRemoteAccess } From 8d7ea80ffdf733cf0790444ec87214731086c3cc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 17:33:58 +0700 Subject: [PATCH 07/54] fix(mcp): remove conflicting port argument to NWListener NWListener(using:on:) with parameters.requiredLocalEndpoint already set produces EINVAL because the port and the endpoint conflict. The endpoint in NWParameters carries both host and port; passing 'on: port' tries to override the port at the listener constructor and the framework rejects it with errno 22. Use NWListener(using:) and let the configured endpoint in parameters drive the bind. --- TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift index 51d85890a..abb9d85fa 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -81,10 +81,8 @@ public actor MCPHttpServerTransport { let parameters: NWParameters = makeParameters() - let port = NWEndpoint.Port(rawValue: configuration.port) ?? .any - do { - let newListener = try NWListener(using: parameters, on: port) + let newListener = try NWListener(using: parameters) listener = newListener newListener.stateUpdateHandler = { [weak self] state in From 0caee43b151ab966967e060aba1612ae7c8841fc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 17:35:02 +0700 Subject: [PATCH 08/54] chore: gitignore profraw + .claude worktrees, register new MCP tool strings Auto-extracted localization keys from the new Phase 4 tool descriptions land in Localizable.xcstrings. .gitignore extended to cover Xcode coverage output (.profraw) and the Claude Code per-session worktree directory so they don't show as untracked clutter on other branches. --- .gitignore | 8 +- TablePro/Resources/Localizable.xcstrings | 144 +++++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index af9f0762c..c525d8208 100644 --- a/.gitignore +++ b/.gitignore @@ -44,11 +44,17 @@ playground.xcworkspace # # Xcode automatically generates this directory with a hierarchical structure of links to # temporary directories for debugging Swift packages. -.swiftpm/xcode +.swiftpm/ .build/ LocalPackages/*/Package.resolved +# Coverage profile output +*.profraw + +# Claude Code worktrees and per-session state +.claude/worktrees/ + # CocoaPods # # We recommend against adding the Pods directory to your .gitignore. However diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index e45d8fc82..7537eebb9 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -10716,6 +10716,9 @@ } } } + }, + "Connect to a saved database" : { + }, "Connect to popular databases with full feature support" : { "localizations" : { @@ -13832,6 +13835,15 @@ } } } + }, + "Database name (uses connection's current database if omitted)" : { + + }, + "Database name to switch to" : { + + }, + "Database Schema" : { + }, "Database Size" : { "localizations" : { @@ -15681,6 +15693,9 @@ } } } + }, + "Disconnect from a database" : { + }, "Disconnected" : { "localizations" : { @@ -17973,6 +17988,9 @@ } } } + }, + "Execute a destructive DDL query (DROP, TRUNCATE, ALTER...DROP) after explicit confirmation." : { + }, "Execute a query to view results as JSON" : { "localizations" : { @@ -17995,6 +18013,9 @@ } } } + }, + "Execute a SQL query. All queries are subject to the connection's safe mode policy. DROP/TRUNCATE/ALTER...DROP must use the confirm_destructive_operation tool." : { + }, "Execute All" : { "localizations" : { @@ -19089,6 +19110,9 @@ } } } + }, + "Export format: csv, json, or sql" : { + }, "Export Formats" : { "localizations" : { @@ -19177,6 +19201,9 @@ } } } + }, + "Export query results or table data to CSV, JSON, or SQL" : { + }, "Export query results to %@" : { "localizations" : { @@ -20859,6 +20886,9 @@ } } } + }, + "File path inside the user's Downloads directory (returns inline data if omitted). Paths outside Downloads are rejected." : { + }, "Filename cannot be '.' or '..' or contain path traversal" : { "localizations" : { @@ -21393,6 +21423,9 @@ } } } + }, + "Focus an already-open tab by id (returned from list_recent_tabs)." : { + }, "Focus Border" : { "localizations" : { @@ -22055,6 +22088,12 @@ } } } + }, + "Get detailed status of a database connection" : { + + }, + "Get detailed table structure: columns, indexes, foreign keys, and DDL" : { + }, "Get help writing queries, explaining schemas, or fixing errors." : { "localizations" : { @@ -22121,6 +22160,9 @@ } } } + }, + "Get the CREATE TABLE DDL statement for a table" : { + }, "GitHub Repository" : { "localizations" : { @@ -26501,6 +26543,24 @@ } } } + }, + "List all databases on the server" : { + + }, + "List all saved database connections with their status" : { + + }, + "List currently open tabs across all TablePro windows. Returns connection, tab type, table name, and titles for each tab." : { + + }, + "List of all saved database connections with metadata" : { + + }, + "List schemas in a database" : { + + }, + "List tables and views in a database" : { + }, "Load" : { "extractionState" : "stale", @@ -27528,6 +27588,12 @@ } } } + }, + "Maximum rows to export (default 50000)" : { + + }, + "Maximum rows to return (default 500, max 10000)" : { + }, "Maximum time to wait for a query to complete. Set to 0 for no limit. Applied to new connections." : { "localizations" : { @@ -28381,6 +28447,9 @@ } } } + }, + "Must be exactly: I understand this is irreversible" : { + }, "My Server" : { "localizations" : { @@ -31702,6 +31771,12 @@ } } } + }, + "Open a table tab in TablePro for the given connection." : { + + }, + "Open a TablePro window for a saved connection (focuses if already open)." : { + }, "Open Claude Desktop, go to Settings > Developer" : { "localizations" : { @@ -35446,6 +35521,9 @@ } } } + }, + "Query history for %@" : { + }, "Query History:" : { "extractionState" : "stale", @@ -35615,6 +35693,9 @@ } } } + }, + "Query timeout in seconds (default 30, max 300)" : { + }, "Query timeout:" : { "localizations" : { @@ -36350,6 +36431,12 @@ } } } + }, + "Recent query history for a connection (supports ?limit=, ?search=, ?date_filter=)" : { + + }, + "Recent query history for this connection" : { + }, "Reconnect" : { "localizations" : { @@ -39364,6 +39451,15 @@ } } } + }, + "Schema for %@" : { + + }, + "Schema name (for multi-schema databases)" : { + + }, + "Schema name to switch to" : { + }, "Schema Switch Failed" : { "localizations" : { @@ -39652,6 +39748,9 @@ } } } + }, + "Search saved query history. Returns matching entries with execution time, row count, and outcome." : { + }, "Search schemas..." : { "localizations" : { @@ -42549,6 +42648,9 @@ } } } + }, + "SQL or NoSQL query text" : { + }, "SQL Preview" : { "localizations" : { @@ -42593,6 +42695,9 @@ } } } + }, + "SQL query to export results from" : { + }, "SQL Server" : { "extractionState" : "stale", @@ -44256,6 +44361,12 @@ } } } + }, + "Switch the active database on a connection" : { + + }, + "Switch the active schema on a connection" : { + }, "Switch to Inline Configuration" : { "localizations" : { @@ -44284,6 +44395,12 @@ } } } + }, + "Switch to this database before executing" : { + + }, + "Switch to this schema before executing" : { + }, "Sync" : { "extractionState" : "stale", @@ -44936,6 +45053,9 @@ } } } + }, + "Table name to open" : { + }, "Table Name:" : { "localizations" : { @@ -44958,6 +45078,9 @@ } } } + }, + "Table names to export (alternative to query)" : { + }, "Table: %@" : { "localizations" : { @@ -45068,6 +45191,12 @@ } } } + }, + "Tables, columns, indexes, and foreign keys for a connected database" : { + + }, + "Tables, columns, indexes, and foreign keys for the connected database" : { + }, "Tablespace" : { "extractionState" : "stale", @@ -45629,6 +45758,9 @@ } } } + }, + "The destructive query to execute" : { + }, "The encrypted file is corrupt or incomplete" : { "localizations" : { @@ -49590,6 +49722,18 @@ } } } + }, + "UUID of the active connection" : { + + }, + "UUID of the connection" : { + + }, + "UUID of the connection to disconnect" : { + + }, + "UUID of the saved connection" : { + }, "v%@" : { "localizations" : { From 1f8d7f2abc5bedc87bd0b92c2bcb9087b65f0adb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 17:36:36 +0700 Subject: [PATCH 09/54] fix(mcp): in-app setup snippets use stdio command form The 'Setup for Claude Desktop / Cursor' helpers were emitting JSON with the 'url' transport key, which Claude Desktop rejects entirely (it only speaks stdio) and which makes Cursor reach the HTTP server directly with no bridge. Both should point at the bundled tablepro-mcp binary so the stdio bridge handles the handshake and lazy-launch. The Claude Code command line is updated to the same form: 'claude mcp add tablepro -- ' instead of '--transport http '. Path is computed from Bundle.main so it works for non-/Applications installs (Setapp, DerivedData, etc.). --- TablePro/Views/Settings/Sections/MCPSection.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Settings/Sections/MCPSection.swift b/TablePro/Views/Settings/Sections/MCPSection.swift index 80bdce11b..c462328c1 100644 --- a/TablePro/Views/Settings/Sections/MCPSection.swift +++ b/TablePro/Views/Settings/Sections/MCPSection.swift @@ -244,7 +244,9 @@ private struct MCPSetupInstructions: View { .font(.callout) } - private var url: String { "http://127.0.0.1:\(port)/mcp" } + private var bridgeBinaryPath: String { + Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/tablepro-mcp").path + } private var steps: [String] { switch tool { @@ -273,7 +275,7 @@ private struct MCPSetupInstructions: View { { "mcpServers": { "tablepro": { - "url": "\(url)" + "command": "\(bridgeBinaryPath)" } } } @@ -285,7 +287,7 @@ private struct MCPSetupInstructions: View { private var command: String? { switch tool { - case .claudeCode: "claude mcp add tablepro --transport http \(url)" + case .claudeCode: "claude mcp add tablepro -- \(bridgeBinaryPath)" default: nil } } From ded173f48b101c57d4e54aff91c44bd9fd246546 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 17:48:56 +0700 Subject: [PATCH 10/54] fix(mcp): wait for connection.send to flush before cancelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpConnectionContext.send was fire-and-forget — it queued the response bytes via NWConnection.send but returned immediately. handleReceive called context.cancel() right after, racing the flush, so URLSession got 'connection lost' instead of the response body. Make send await the .contentProcessed callback (via withCheckedContinuation) and propagate the await up through writeJsonResponse, writeOptions204, writeNoContent, writeAccepted, writeSseStreamHeaders, writeSseFrame, writeRaw. Cancel now safely runs only after the network framework confirms the data has been handed off. --- .../Transport/MCPHttpServerTransport.swift | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift index abb9d85fa..ac44ecc45 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -744,7 +744,7 @@ actor HttpConnectionContext { status: HttpStatus, sessionId: MCPSessionId?, extraHeaders: [(String, String)] - ) { + ) async { if cancelled { return } var headers: [(String, String)] = [ ("Content-Type", "application/json"), @@ -757,37 +757,37 @@ actor HttpConnectionContext { headers.append(contentsOf: MCPCorsHeaders.standard) let head = HttpResponseHead(status: status, headers: HttpHeaders(headers)) let payload = HttpResponseEncoder.encode(head, body: data) - send(payload) + await send(payload) } - func writeOptions204() { + func writeOptions204() async { if cancelled { return } var headers: [(String, String)] = [("Connection", "close")] headers.append(contentsOf: MCPCorsHeaders.standard) let head = HttpResponseHead(status: .noContent, headers: HttpHeaders(headers)) let payload = HttpResponseEncoder.encode(head, body: nil) - send(payload) + await send(payload) } - func writeNoContent() { + func writeNoContent() async { if cancelled { return } var headers: [(String, String)] = [("Connection", "close")] headers.append(contentsOf: MCPCorsHeaders.standard) let head = HttpResponseHead(status: .noContent, headers: HttpHeaders(headers)) let payload = HttpResponseEncoder.encode(head, body: nil) - send(payload) + await send(payload) } - func writeAccepted() { + func writeAccepted() async { if cancelled { return } var headers: [(String, String)] = [("Connection", "close")] headers.append(contentsOf: MCPCorsHeaders.standard) let head = HttpResponseHead(status: .accepted, headers: HttpHeaders(headers)) let payload = HttpResponseEncoder.encode(head, body: nil) - send(payload) + await send(payload) } - func writeSseStreamHeaders(sessionId: MCPSessionId) { + func writeSseStreamHeaders(sessionId: MCPSessionId) async { if cancelled { return } sseActive = true var headers: [(String, String)] = [ @@ -799,18 +799,18 @@ actor HttpConnectionContext { headers.append(contentsOf: MCPCorsHeaders.standard) let head = HttpResponseHead(status: .ok, headers: HttpHeaders(headers)) let payload = HttpResponseEncoder.encode(head, body: nil) - send(payload) + await send(payload) } - func writeSseFrame(_ frame: SseFrame) { + func writeSseFrame(_ frame: SseFrame) async { if cancelled { return } let data = SseEncoder.encode(frame) - send(data) + await send(data) } - func writeRaw(_ data: Data) { + func writeRaw(_ data: Data) async { if cancelled { return } - send(data) + await send(data) } func cancel() { @@ -823,12 +823,15 @@ actor HttpConnectionContext { sseActive } - private func send(_ data: Data) { - connection.send(content: data, completion: .contentProcessed { error in - if let error { - Self.logger.debug("Send error: \(error.localizedDescription, privacy: .public)") - } - }) + private func send(_ data: Data) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + Self.logger.debug("Send error: \(error.localizedDescription, privacy: .public)") + } + continuation.resume() + }) + } } } From fff5cddfceebe429802e0eac3bfe8b652ffd2fb5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 17:57:10 +0700 Subject: [PATCH 11/54] fix(mcp): wire audit log entries for tool calls, queries, resources, auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToolsCallHandler now records every tool invocation (success/denied/error) with the principal's token label and the tool's connection_id argument (when present). ResourcesReadHandler does the same for resources/read. ToolQueryExecutor adds an MCPAuditLogger.logQueryExecuted entry alongside the existing QueryHistoryStorage write so SQL execution shows up in the Activity Log. MCPBearerTokenAuthenticator records auth success/failure/ rate-limit events with the client IP. Drops the 'legacy' label from the domain error mapper in ResourcesReadHandler — MCPError is the data layer's domain error type, not legacy code. --- .../Auth/MCPBearerTokenAuthenticator.swift | 21 ++++++++ .../Handlers/ResourcesReadHandler.swift | 49 +++++++++++++------ .../Protocol/Handlers/ToolsCallHandler.swift | 37 +++++++++++++- .../ConfirmDestructiveOperationTool.swift | 3 +- .../MCP/Protocol/Tools/ExecuteQueryTool.swift | 3 +- .../Protocol/Tools/ToolQueryExecutor.swift | 22 ++++++++- 6 files changed, 114 insertions(+), 21 deletions(-) diff --git a/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift index 7962b88f7..0e41fabc9 100644 --- a/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift +++ b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift @@ -75,23 +75,29 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { authorizationHeader: String?, clientAddress: MCPClientAddress ) async -> MCPAuthDecision { + let ipString = Self.ipString(for: clientAddress) + guard let header = authorizationHeader, !header.isEmpty else { let key = MCPRateLimitKey(clientAddress: clientAddress, principalFingerprint: nil) if await rateLimiter.isLocked(key: key) { Self.logger.warning("Auth rejected (rate limited, missing header)") + MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: 0) return .deny(.rateLimited()) } Self.logger.info("Auth missing Authorization header") + MCPAuditLogger.logAuthFailure(reason: "missing_authorization_header", ip: ipString) return .deny(.unauthenticated(reason: "missing_authorization_header")) } guard let token = Self.parseBearerToken(header) else { let key = MCPRateLimitKey(clientAddress: clientAddress, principalFingerprint: nil) if await rateLimiter.isLocked(key: key) { + MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: 0) return .deny(.rateLimited()) } _ = await rateLimiter.recordAttempt(key: key, success: false) Self.logger.info("Auth invalid Authorization scheme") + MCPAuditLogger.logAuthFailure(reason: "invalid_authorization_scheme", ip: ipString) return .deny(.unauthenticated(reason: "invalid_authorization_scheme")) } @@ -105,6 +111,7 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { Self.logger.warning( "Auth rate limited fingerprint=\(fingerprint, privacy: .public)" ) + MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: 0) return .deny(.rateLimited()) } @@ -113,17 +120,21 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { case .failure(let error): let verdict = await rateLimiter.recordAttempt(key: principalKey, success: false) if case .lockedUntil = verdict { + MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: 0) return .deny(.rateLimited()) } switch error { case .unknownToken: Self.logger.info("Auth unknown token fingerprint=\(fingerprint, privacy: .public)") + MCPAuditLogger.logAuthFailure(reason: "unknown_token", ip: ipString) return .deny(.tokenInvalid(reason: "unknown_token")) case .expired: Self.logger.info("Auth expired token fingerprint=\(fingerprint, privacy: .public)") + MCPAuditLogger.logAuthFailure(reason: "expired_token", ip: ipString) return .deny(.tokenExpired()) case .revoked: Self.logger.info("Auth revoked token fingerprint=\(fingerprint, privacy: .public)") + MCPAuditLogger.logAuthFailure(reason: "revoked_token", ip: ipString) return .deny(.tokenInvalid(reason: "token_revoked")) } @@ -139,10 +150,20 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { ) ) Self.logger.info("Auth allowed fingerprint=\(fingerprint, privacy: .public)") + MCPAuditLogger.logAuthSuccess(tokenName: validated.label ?? "-", ip: ipString) return .allow(principal) } } + private static func ipString(for address: MCPClientAddress) -> String { + switch address { + case .loopback: + return "127.0.0.1" + case .remote(let host): + return host + } + } + internal static func parseBearerToken(_ header: String) -> String? { let trimmed = header.trimmingCharacters(in: .whitespacesAndNewlines) guard let spaceIndex = trimmed.firstIndex(of: " ") else { return nil } diff --git a/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift index bed4c315c..cb7ad43bd 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift @@ -19,22 +19,39 @@ public struct ResourcesReadHandler: MCPMethodHandler { throw MCPProtocolError.invalidParams(detail: "Missing required parameter: uri") } - let route = try Self.parseRoute(uri: uri) - let payload = try await Self.fetchPayload(for: route, services: services) - let text = Self.encodeJsonString(payload) - - let result: JsonValue = .object([ - "contents": .array([ - .object([ - "uri": .string(uri), - "mimeType": .string("application/json"), - "text": .string(text) + do { + let route = try Self.parseRoute(uri: uri) + let payload = try await Self.fetchPayload(for: route, services: services) + let text = Self.encodeJsonString(payload) + + let result: JsonValue = .object([ + "contents": .array([ + .object([ + "uri": .string(uri), + "mimeType": .string("application/json"), + "text": .string(text) + ]) ]) ]) - ]) - Self.logger.debug("resources/read uri=\(uri, privacy: .public)") - return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) + Self.logger.debug("resources/read uri=\(uri, privacy: .public)") + MCPAuditLogger.logResourceRead( + tokenId: nil, + tokenName: context.principal.metadata.label, + uri: uri, + outcome: .success + ) + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) + } catch { + MCPAuditLogger.logResourceRead( + tokenId: nil, + tokenName: context.principal.metadata.label, + uri: uri, + outcome: .error, + errorMessage: (error as? MCPProtocolError)?.message ?? error.localizedDescription + ) + throw error + } } private enum ResourceRoute { @@ -102,7 +119,7 @@ public struct ResourcesReadHandler: MCPMethodHandler { do { return try await services.connectionBridge.fetchSchemaResource(connectionId: connectionId) } catch let error as MCPError { - throw mapLegacyError(error) + throw mapDomainError(error) } case .connectionHistory(let connectionId, let limit, let search, let dateFilter): @@ -114,12 +131,12 @@ public struct ResourcesReadHandler: MCPMethodHandler { dateFilter: dateFilter ) } catch let error as MCPError { - throw mapLegacyError(error) + throw mapDomainError(error) } } } - private static func mapLegacyError(_ error: MCPError) -> MCPProtocolError { + private static func mapDomainError(_ error: MCPError) -> MCPProtocolError { switch error { case .invalidParams(let detail): return MCPProtocolError.invalidParams(detail: detail) diff --git a/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift index 20935de05..6674a4714 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift @@ -29,12 +29,45 @@ public struct ToolsCallHandler: MCPMethodHandler { let toolType = type(of: tool) if !toolType.requiredScopes.isSubset(of: context.principal.scopes) { + MCPAuditLogger.logToolCalled( + tokenId: nil, + tokenName: context.principal.metadata.label, + toolName: toolName, + connectionId: Self.connectionId(in: arguments), + outcome: .denied, + errorMessage: "missing_scope" + ) throw MCPProtocolError.forbidden(reason: "Tool '\(toolName)' requires additional scopes") } Self.logger.info("tools/call name=\(toolName, privacy: .public)") - let result = try await tool.call(arguments: arguments, context: context, services: services) - return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result.asJsonValue()) + do { + let result = try await tool.call(arguments: arguments, context: context, services: services) + MCPAuditLogger.logToolCalled( + tokenId: nil, + tokenName: context.principal.metadata.label, + toolName: toolName, + connectionId: Self.connectionId(in: arguments), + outcome: result.isError ? .error : .success + ) + return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result.asJsonValue()) + } catch { + MCPAuditLogger.logToolCalled( + tokenId: nil, + tokenName: context.principal.metadata.label, + toolName: toolName, + connectionId: Self.connectionId(in: arguments), + outcome: .error, + errorMessage: (error as? MCPProtocolError)?.message ?? error.localizedDescription + ) + throw error + } + } + + private static func connectionId(in arguments: JsonValue) -> UUID? { + guard case .object(let object) = arguments, + case .string(let value)? = object["connection_id"] else { return nil } + return UUID(uuidString: value) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift b/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift index 5cf4d1742..baa07715a 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift @@ -83,7 +83,8 @@ public struct ConfirmDestructiveOperationTool: MCPToolImplementation { connectionId: connectionId, databaseName: meta.databaseName, maxRows: 0, - timeoutSeconds: timeoutSeconds + timeoutSeconds: timeoutSeconds, + principalLabel: context.principal.metadata.label ) return .json(result) diff --git a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift index d99f2a475..766691353 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift @@ -122,7 +122,8 @@ public struct ExecuteQueryTool: MCPToolImplementation { connectionId: connectionId, databaseName: meta.databaseName, maxRows: maxRows, - timeoutSeconds: timeoutSeconds + timeoutSeconds: timeoutSeconds, + principalLabel: context.principal.metadata.label ) try await throwIfCancelled(context) diff --git a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift index e0f4a0309..fac07a71b 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift @@ -7,7 +7,8 @@ enum ToolQueryExecutor { connectionId: UUID, databaseName: String, maxRows: Int, - timeoutSeconds: Int + timeoutSeconds: Int, + principalLabel: String? ) async throws -> JsonValue { let startTime = Date() do { @@ -28,6 +29,15 @@ enum ToolQueryExecutor { wasSuccessful: true, errorMessage: nil ) + MCPAuditLogger.logQueryExecuted( + tokenId: nil, + tokenName: principalLabel, + connectionId: connectionId, + sql: query, + durationMs: Int(elapsed * 1000), + rowCount: rowCount, + outcome: .success + ) return result } catch { let elapsed = Date().timeIntervalSince(startTime) @@ -40,6 +50,16 @@ enum ToolQueryExecutor { wasSuccessful: false, errorMessage: error.localizedDescription ) + MCPAuditLogger.logQueryExecuted( + tokenId: nil, + tokenName: principalLabel, + connectionId: connectionId, + sql: query, + durationMs: Int(elapsed * 1000), + rowCount: 0, + outcome: .error, + errorMessage: error.localizedDescription + ) throw error } } From 8bea876268c8d20da7b60870894ebf2edd6ad609 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 18:02:11 +0700 Subject: [PATCH 12/54] refactor(mcp): split MCPError into MCPDataLayerError, drop JSON-RPC overlap The legacy MCPError enum had two kinds of cases mixed together: domain errors thrown by the data layer (notConnected, forbidden, timeout, etc.) and JSON-RPC-shaped errors (parseError, invalidRequest, methodNotFound, internalError, invalidParams) that overlapped with the protocol layer's MCPProtocolError. The unused JSON-RPC cases are deleted; the still-used ones are renamed to domain-flavored names (.invalidParams -> .invalid Argument, .internalError -> .dataSourceError) so the data layer no longer borrows protocol-layer terminology. mapDomainError in ResourcesReadHandler now translates the full set of domain errors into appropriate MCPProtocolError values (notFound -> resourceNotFound + 404, timeout -> requestTimeout, etc.) without a default case, so future additions to MCPDataLayerError will fail the compile and force an explicit decision about the protocol mapping. --- TablePro/Core/MCP/MCPAuthPolicy.swift | 12 +-- TablePro/Core/MCP/MCPConnectionBridge.swift | 14 ++-- TablePro/Core/MCP/MCPDataLayerError.swift | 42 +++++++++++ TablePro/Core/MCP/MCPError.swift | 75 ------------------- TablePro/Core/MCP/MCPPairingService.swift | 12 +-- .../Handlers/ResourcesReadHandler.swift | 36 ++++++--- 6 files changed, 86 insertions(+), 105 deletions(-) create mode 100644 TablePro/Core/MCP/MCPDataLayerError.swift delete mode 100644 TablePro/Core/MCP/MCPError.swift diff --git a/TablePro/Core/MCP/MCPAuthPolicy.swift b/TablePro/Core/MCP/MCPAuthPolicy.swift index 5421e7431..d1344d9b7 100644 --- a/TablePro/Core/MCP/MCPAuthPolicy.swift +++ b/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -116,11 +116,11 @@ public actor MCPAuthPolicy { return case .denied(let reason): - throw MCPError.forbidden(reason) + throw MCPDataLayerError.forbidden(reason) case .requiresUserApproval(let reason): guard let connectionId else { - throw MCPError.forbidden(reason) + throw MCPDataLayerError.forbidden(reason) } let approved = try await runApprovalDedup( sessionId: sessionId, @@ -130,7 +130,7 @@ public actor MCPAuthPolicy { if approved { recordApproval(sessionId: sessionId, connectionId: connectionId) } else { - throw MCPError.forbidden( + throw MCPDataLayerError.forbidden( String(localized: "User denied MCP access to this connection") ) } @@ -173,7 +173,7 @@ public actor MCPAuthPolicy { ) if case .blocked(let reason) = permission { - throw MCPError.forbidden(reason) + throw MCPDataLayerError.forbidden(reason) } } @@ -228,12 +228,12 @@ public actor MCPAuthPolicy { } group.addTask { try await Task.sleep(for: .seconds(30)) - throw MCPError.timeout( + throw MCPDataLayerError.timeout( String(localized: "User approval timed out after 30 seconds") ) } guard let result = try await group.next() else { - throw MCPError.internalError("No result from approval prompt") + throw MCPDataLayerError.dataSourceError("No result from approval prompt") } return result } diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index f42f05262..4d7970431 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -91,7 +91,7 @@ public actor MCPConnectionBridge { DatabaseManager.shared.activeSessions[connectionId] != nil } guard sessionExists else { - throw MCPError.notConnected(connectionId) + throw MCPDataLayerError.notConnected(connectionId) } await DatabaseManager.shared.disconnectSession(connectionId) } @@ -106,7 +106,7 @@ public actor MCPConnectionBridge { } guard let core else { - throw MCPError.notConnected(connectionId) + throw MCPDataLayerError.notConnected(connectionId) } let meta = await MainActor.run { @@ -182,10 +182,10 @@ public actor MCPConnectionBridge { group.addTask { try await Task.sleep(for: .seconds(timeoutSeconds)) try? driver.cancelQuery() - throw MCPError.timeout("Query timed out after \(timeoutSeconds) seconds") + throw MCPDataLayerError.timeout("Query timed out after \(timeoutSeconds) seconds") } guard let first = try await group.next() else { - throw MCPError.internalError("No result from query execution") + throw MCPDataLayerError.dataSourceError("No result from query execution") } group.cancelAll() return first @@ -458,7 +458,7 @@ public actor MCPConnectionBridge { case .live(let driver, let session): return (driver, session.connection.type) case .stored, .unknown: - throw MCPError.notConnected(connectionId) + throw MCPDataLayerError.notConnected(connectionId) } } } @@ -470,7 +470,7 @@ public actor MCPConnectionBridge { private func resolveSession(_ connectionId: UUID) async throws -> ConnectionSession { try await MainActor.run { guard let session = DatabaseManager.shared.activeSessions[connectionId] else { - throw MCPError.notConnected(connectionId) + throw MCPDataLayerError.notConnected(connectionId) } return session } @@ -480,7 +480,7 @@ public actor MCPConnectionBridge { try await MainActor.run { let connections = ConnectionStorage.shared.loadConnections() guard let connection = connections.first(where: { $0.id == connectionId }) else { - throw MCPError.invalidParams("Connection not found: \(connectionId)") + throw MCPDataLayerError.invalidArgument("Connection not found: \(connectionId)") } return connection } diff --git a/TablePro/Core/MCP/MCPDataLayerError.swift b/TablePro/Core/MCP/MCPDataLayerError.swift new file mode 100644 index 000000000..dfaf68c90 --- /dev/null +++ b/TablePro/Core/MCP/MCPDataLayerError.swift @@ -0,0 +1,42 @@ +import Foundation + +enum MCPDataLayerError: Error, Sendable { + case notConnected(UUID) + case invalidArgument(String) + case forbidden(String, context: [String: String]? = nil) + case timeout(String, context: [String: String]? = nil) + case notFound(String) + case expired(String) + case userCancelled + case dataSourceError(String) + + var message: String { + switch self { + case .notConnected(let connectionId): + "Not connected: \(connectionId)" + case .invalidArgument(let detail): + "Invalid argument: \(detail)" + case .forbidden(let detail, _): + "Forbidden: \(detail)" + case .timeout(let detail, _): + "Timeout: \(detail)" + case .notFound(let detail): + "Not found: \(detail)" + case .expired(let detail): + "Expired: \(detail)" + case .userCancelled: + "User cancelled" + case .dataSourceError(let detail): + "Data source error: \(detail)" + } + } + + var isUserCancelled: Bool { + if case .userCancelled = self { return true } + return false + } +} + +extension MCPDataLayerError: LocalizedError { + var errorDescription: String? { message } +} diff --git a/TablePro/Core/MCP/MCPError.swift b/TablePro/Core/MCP/MCPError.swift deleted file mode 100644 index 842a40575..000000000 --- a/TablePro/Core/MCP/MCPError.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -enum MCPError: Error, Sendable { - case parseError - case invalidRequest(String) - case methodNotFound(String) - case invalidParams(String) - case internalError(String) - case notConnected(UUID) - case forbidden(String, context: [String: String]? = nil) - case timeout(String, context: [String: String]? = nil) - case resultTooLarge - case serverDisabled - case notFound(String) - case expired(String) - case userCancelled - - var code: Int { - switch self { - case .parseError: -32_700 - case .invalidRequest: -32_600 - case .methodNotFound: -32_601 - case .invalidParams: -32_602 - case .internalError: -32_603 - case .notConnected: -32_000 - case .forbidden: -32_001 - case .timeout: -32_002 - case .resultTooLarge: -32_003 - case .serverDisabled: -32_004 - case .notFound: -32_005 - case .expired: -32_006 - case .userCancelled: -32_007 - } - } - - var message: String { - switch self { - case .parseError: - "Parse error" - case .invalidRequest(let detail): - "Invalid request: \(detail)" - case .methodNotFound(let method): - "Method not found: \(method)" - case .invalidParams(let detail): - "Invalid params: \(detail)" - case .internalError(let detail): - "Internal error: \(detail)" - case .notConnected(let connectionId): - "Not connected: \(connectionId)" - case .forbidden(let detail, _): - "Forbidden: \(detail)" - case .timeout(let detail, _): - "Timeout: \(detail)" - case .resultTooLarge: - "Result too large" - case .serverDisabled: - "MCP server is disabled" - case .notFound(let detail): - "Not found: \(detail)" - case .expired(let detail): - "Expired: \(detail)" - case .userCancelled: - "User cancelled" - } - } - - var isUserCancelled: Bool { - if case .userCancelled = self { return true } - return false - } -} - -extension MCPError: LocalizedError { - var errorDescription: String? { message } -} diff --git a/TablePro/Core/MCP/MCPPairingService.swift b/TablePro/Core/MCP/MCPPairingService.swift index 3201bf579..78b01af2a 100644 --- a/TablePro/Core/MCP/MCPPairingService.swift +++ b/TablePro/Core/MCP/MCPPairingService.swift @@ -26,7 +26,7 @@ final class PairingExchangeStore: @unchecked Sendable { defer { lock.unlock() } prune(now: Date.now) guard pending.count < Self.maxPendingCodes else { - throw MCPError.forbidden( + throw MCPDataLayerError.forbidden( String(localized: "Too many pending pairing codes. Try again later.") ) } @@ -39,17 +39,17 @@ final class PairingExchangeStore: @unchecked Sendable { prune(now: now) guard let entry = pending[code] else { - throw MCPError.notFound("pairing code") + throw MCPDataLayerError.notFound("pairing code") } guard entry.expiresAt > now else { pending.removeValue(forKey: code) - throw MCPError.expired("pairing code") + throw MCPDataLayerError.expired("pairing code") } let computed = Self.sha256Base64Url(of: verifier) guard Self.constantTimeEqual(entry.challenge, computed) else { - throw MCPError.forbidden("challenge mismatch") + throw MCPDataLayerError.forbidden("challenge mismatch") } let token = entry.plaintextToken @@ -123,7 +123,7 @@ final class MCPPairingService { guard let tokenStore = MCPServerManager.shared.tokenStore else { Self.logger.error("Token store unavailable after lazyStart") - throw MCPError.internalError("Token store unavailable") + throw MCPDataLayerError.dataSourceError("Token store unavailable") } let approval = try await AlertHelper.runPairingApproval(request: request) @@ -154,7 +154,7 @@ final class MCPPairingService { guard let redirect = buildRedirectURL(base: request.redirectURL, code: code) else { Self.logger.error("Failed to build pairing redirect URL") await tokenStore.delete(tokenId: result.token.id) - throw MCPError.invalidParams("redirect URL") + throw MCPDataLayerError.invalidArgument("redirect URL") } Self.logger.info("Pairing approved for client '\(request.clientName, privacy: .public)'") diff --git a/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift index cb7ad43bd..d557476fc 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/ResourcesReadHandler.swift @@ -118,7 +118,7 @@ public struct ResourcesReadHandler: MCPMethodHandler { case .connectionSchema(let connectionId): do { return try await services.connectionBridge.fetchSchemaResource(connectionId: connectionId) - } catch let error as MCPError { + } catch let error as MCPDataLayerError { throw mapDomainError(error) } @@ -130,32 +130,46 @@ public struct ResourcesReadHandler: MCPMethodHandler { search: search, dateFilter: dateFilter ) - } catch let error as MCPError { + } catch let error as MCPDataLayerError { throw mapDomainError(error) } } } - private static func mapDomainError(_ error: MCPError) -> MCPProtocolError { + private static func mapDomainError(_ error: MCPDataLayerError) -> MCPProtocolError { switch error { - case .invalidParams(let detail): + case .invalidArgument(let detail): return MCPProtocolError.invalidParams(detail: detail) - case .invalidRequest(let detail): - return MCPProtocolError.invalidRequest(detail: detail) case .notConnected(let id): return MCPProtocolError.invalidParams(detail: "Connection not active: \(id.uuidString)") case .forbidden(let reason, _): return MCPProtocolError.forbidden(reason: reason) - case .methodNotFound(let method): - return MCPProtocolError.methodNotFound(method: method) + case .notFound(let detail): + return MCPProtocolError( + code: JsonRpcErrorCode.resourceNotFound, + message: detail, + httpStatus: .notFound + ) + case .expired(let detail): + return MCPProtocolError( + code: JsonRpcErrorCode.expired, + message: detail, + httpStatus: .ok + ) case .timeout(let detail, _): return MCPProtocolError( - code: JsonRpcErrorCode.requestCancelled, + code: JsonRpcErrorCode.requestTimeout, message: "Timeout: \(detail)", httpStatus: .ok ) - default: - return MCPProtocolError.internalError(detail: String(describing: error)) + case .userCancelled: + return MCPProtocolError( + code: JsonRpcErrorCode.requestCancelled, + message: "User cancelled", + httpStatus: .ok + ) + case .dataSourceError(let detail): + return MCPProtocolError.internalError(detail: detail) } } From 454ecd26342927906720bbcbd49e139437a2fc01 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 18:02:32 +0700 Subject: [PATCH 13/54] refactor(mcp): propagate MCPDataLayerError rename to non-MCP callers LaunchIntentRouter, PairingApprovalSheet, and MCPPairingServiceTests also catch the data-layer error type; updated to the new name. --- .../Services/Infrastructure/LaunchIntentRouter.swift | 2 +- .../Views/Settings/Sections/PairingApprovalSheet.swift | 2 +- TableProTests/Core/MCP/MCPPairingServiceTests.swift | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift index 7a60f1959..cd31312d8 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift @@ -43,7 +43,7 @@ internal final class LaunchIntentRouter { } } catch let error as TabRouterError where error == .userCancelled { Self.logger.info("Intent cancelled by user") - } catch let error as MCPError where error.isUserCancelled { + } catch let error as MCPDataLayerError where error.isUserCancelled { Self.logger.info("Pairing cancelled by user") } catch is CancellationError { Self.logger.info("Intent cancelled") diff --git a/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift index 521eaf3ea..1335978de 100644 --- a/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift +++ b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift @@ -207,7 +207,7 @@ struct PairingApprovalSheet: View { private var actionBar: some View { HStack { Button(String(localized: "Deny"), role: .cancel) { - onComplete(.failure(MCPError.userCancelled)) + onComplete(.failure(MCPDataLayerError.userCancelled)) } .keyboardShortcut(.cancelAction) diff --git a/TableProTests/Core/MCP/MCPPairingServiceTests.swift b/TableProTests/Core/MCP/MCPPairingServiceTests.swift index 725508f9e..1ac1ccb61 100644 --- a/TableProTests/Core/MCP/MCPPairingServiceTests.swift +++ b/TableProTests/Core/MCP/MCPPairingServiceTests.swift @@ -55,7 +55,7 @@ struct MCPPairingServiceTests { _ = try store.consume(code: "code-3", verifier: verifier) - #expect(throws: MCPError.self) { + #expect(throws: MCPDataLayerError.self) { try store.consume(code: "code-3", verifier: verifier) } } @@ -67,7 +67,7 @@ struct MCPPairingServiceTests { do { _ = try store.consume(code: "missing", verifier: "any") Issue.record("Expected notFound error") - } catch let error as MCPError { + } catch let error as MCPDataLayerError { guard case .notFound = error else { Issue.record("Expected notFound, got \(error)") return @@ -87,7 +87,7 @@ struct MCPPairingServiceTests { do { _ = try store.consume(code: "code-4", verifier: verifier, now: Date.now) Issue.record("Expected expired error") - } catch let error as MCPError { + } catch let error as MCPDataLayerError { guard case .expired = error else { Issue.record("Expected expired, got \(error)") return @@ -106,7 +106,7 @@ struct MCPPairingServiceTests { do { _ = try store.consume(code: "code-5", verifier: "attacker-verifier") Issue.record("Expected forbidden error") - } catch let error as MCPError { + } catch let error as MCPDataLayerError { guard case .forbidden = error else { Issue.record("Expected forbidden, got \(error)") return @@ -195,7 +195,7 @@ struct MCPPairingServiceTests { record: record(plaintext: "tp_x", challenge: "challenge", expiresIn: 60) ) Issue.record("Expected forbidden error after exceeding maxPendingCodes") - } catch let error as MCPError { + } catch let error as MCPDataLayerError { guard case .forbidden = error else { Issue.record("Expected forbidden, got \(error)") return From 0b669ba48b6ccc9725d50ed0443ebfca276f19e4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 18:11:40 +0700 Subject: [PATCH 14/54] fix(mcp): writeback legacy token format on first load + restructure CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCPTokenStore now detects the old allowedConnectionIds field via a byte scan and re-saves immediately after decode, so stale on-disk format disappears after one launch instead of lingering forever. The decoder side already handled both shapes — this just forces the encode pass. CHANGELOG split the rewrite into Added (streaming progress as a real new capability), Fixed (5 user-facing bugs that are gone), and Changed (idle timeout + the 'this is a rewrite, but transparent to you' note). --- CHANGELOG.md | 12 ++++++++++-- TablePro/Core/MCP/MCPTokenStore.swift | 10 ++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a74172392..4824ed5ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed -- MCP: complete rewrite of the server, bridge, and protocol layer for spec compliance and reliability. Non-2xx responses now emit JSON-RPC error envelopes (the bridge previously forwarded plain `{"error":"..."}` bodies, which broke Claude Desktop's stdio parser when sessions expired). The stdio bridge no longer polls `availableData` for stdin (which silently exited mid-session) and uses incremental SSE parsing instead of buffering full responses. Idle session timeout raised from 5 to 15 minutes. Rate limiter now keys on `(client_address, principal_fingerprint)` instead of IP only, fixing a localhost auth-DoS surface. Streaming progress notifications via `notifications/progress` are now supported for long-running tool calls. +### Added +- MCP: streaming progress notifications. Long-running tool calls (e.g. `execute_query`) now emit `notifications/progress` events to clients that pass a `_meta.progressToken` in their request. ### Fixed - MCP: stale `Mcp-Session-Id` after idle timeout now produces a JSON-RPC `-32001 "Session not found"` envelope with HTTP 404, matching the spec and letting clients re-initialize cleanly. Previously the bridge forwarded a plain `{"error":"Session not found"}` body that Claude Desktop's parser rejected, hanging the request until a 4-minute client-side timeout fired. +- MCP: stdio bridge no longer exits silently when stdin is briefly empty (was reading via `availableData`, which can't tell EOF from "no bytes right now"). Now uses `FileHandle.bytes` AsyncBytes. +- MCP: SSE responses now stream incrementally instead of buffering the entire body before delivering events. +- MCP: localhost auth-DoS surface closed. The rate limiter now keys on `(client_address, principal_fingerprint)` so failed attempts from one bridge can't lock out another. +- MCP: in-app "Setup for Claude Desktop / Cursor" snippets now use the stdio command form pointing at the bundled `tablepro-mcp` binary. The previous `"url"` form was rejected by Claude Desktop entirely. + +### Changed +- MCP: idle session timeout raised from 5 to 15 minutes. +- MCP: complete internal rewrite of the server, stdio bridge, and protocol dispatcher for spec compliance. Public API of `MCPServerManager` and the on-disk handshake format are unchanged; clients do not need to re-pair. ## [0.37.0] - 2026-05-01 diff --git a/TablePro/Core/MCP/MCPTokenStore.swift b/TablePro/Core/MCP/MCPTokenStore.swift index dc71eaab8..427f2bdcb 100644 --- a/TablePro/Core/MCP/MCPTokenStore.swift +++ b/TablePro/Core/MCP/MCPTokenStore.swift @@ -269,8 +269,10 @@ actor MCPTokenStore { return } + var migratedFromLegacyFormat = false do { let data = try Data(contentsOf: storageUrl) + migratedFromLegacyFormat = data.contains(Self.legacyConnectionAccessKey) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 tokens = try decoder.decode([MCPAuthToken].self, from: data) @@ -284,9 +286,17 @@ actor MCPTokenStore { tokens.removeAll { $0.name == Self.stdioBridgeTokenName } save() Self.logger.info("Cleaned up \(staleCount) stale bridge token(s)") + return + } + + if migratedFromLegacyFormat { + save() + Self.logger.info("Migrated MCP token file from allowedConnectionIds to connectionAccess") } } + private static let legacyConnectionAccessKey = Data("allowedConnectionIds".utf8) + private func saveIfCooldownElapsed() { let now = ContinuousClock.now guard now - lastSavedAt > Self.saveCooldown else { return } From ff63a90019d570e43dd52fde2765bbce81c51022 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 18:13:02 +0700 Subject: [PATCH 15/54] refactor(mcp): drop legacy allowedConnectionIds compat path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old MCP wasn't shipped to users yet, so there's no on-disk format to migrate. Removes the dual-decode branch and the byte-scan migration writeback. Tokens persisted before this change with the old field name won't decode — that's fine, they were never released. --- TablePro/Core/MCP/MCPTokenStore.swift | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/TablePro/Core/MCP/MCPTokenStore.swift b/TablePro/Core/MCP/MCPTokenStore.swift index 427f2bdcb..48fbaeceb 100644 --- a/TablePro/Core/MCP/MCPTokenStore.swift +++ b/TablePro/Core/MCP/MCPTokenStore.swift @@ -76,7 +76,6 @@ struct MCPAuthToken: Codable, Identifiable, Sendable { case salt case permissions case connectionAccess - case allowedConnectionIds case createdAt case lastUsedAt case expiresAt @@ -95,14 +94,7 @@ struct MCPAuthToken: Codable, Identifiable, Sendable { self.lastUsedAt = try container.decodeIfPresent(Date.self, forKey: .lastUsedAt) self.expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt) self.isActive = try container.decode(Bool.self, forKey: .isActive) - - if let access = try container.decodeIfPresent(ConnectionAccess.self, forKey: .connectionAccess) { - self.connectionAccess = access - } else if let legacyIds = try container.decodeIfPresent(Set.self, forKey: .allowedConnectionIds) { - self.connectionAccess = .limited(legacyIds) - } else { - self.connectionAccess = .all - } + self.connectionAccess = try container.decodeIfPresent(ConnectionAccess.self, forKey: .connectionAccess) ?? .all } func encode(to encoder: Encoder) throws { @@ -269,10 +261,8 @@ actor MCPTokenStore { return } - var migratedFromLegacyFormat = false do { let data = try Data(contentsOf: storageUrl) - migratedFromLegacyFormat = data.contains(Self.legacyConnectionAccessKey) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 tokens = try decoder.decode([MCPAuthToken].self, from: data) @@ -286,17 +276,9 @@ actor MCPTokenStore { tokens.removeAll { $0.name == Self.stdioBridgeTokenName } save() Self.logger.info("Cleaned up \(staleCount) stale bridge token(s)") - return - } - - if migratedFromLegacyFormat { - save() - Self.logger.info("Migrated MCP token file from allowedConnectionIds to connectionAccess") } } - private static let legacyConnectionAccessKey = Data("allowedConnectionIds".utf8) - private func saveIfCooldownElapsed() { let now = ContinuousClock.now guard now - lastSavedAt > Self.saveCooldown else { return } From 8701df87a090e70ebaa75caffdd7757ae1c8bb74 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 18:18:29 +0700 Subject: [PATCH 16/54] refactor(mcp): convert transports to actors, drop @unchecked Sendable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCPStdioMessageTransport and MCPStreamableHttpClientTransport are now actors. The previous outer-class-with-NSLock-around-internal-actor pattern carried @unchecked Sendable; converting to actors gets compiler- verified Sendable for free. Internal mutable state (readerTask, isClosed, sessionId, pendingRequests, etc.) is actor-isolated. The 'inbound' stream stays nonisolated public let so existing callers like BridgeProxy keep working without await on the property access. The 'var capturedContinuation: ...!' implicitly-unwrapped continuation capture is replaced by a small StreamContinuationBox value type that holds the continuation behind a lock. Sendable without @unchecked. MCPBridgeLogger.StderrWriter is also now an actor; the sync log() API is preserved by spawning a fire-and-forget Task — log ordering is best- effort, which is fine for stderr. Adds 15 new tool test files (DescribeTable, Disconnect, ExportData, FocusQueryTab, GetConnectionStatus, GetTableDdl, ListConnections, ListDatabases, ListRecentTabs, ListSchemas, ListTables, OpenConnection Window, OpenTableTab, SearchQueryHistory, SwitchSchema). Each follows the ConnectToolTests pattern: metadata + missing-arg + malformed-arg. Fixes a switch-vs-struct typo in two tests. --- .../Core/MCP/Transport/MCPBridgeLogger.swift | 15 ++- .../Transport/MCPStdioMessageTransport.swift | 99 ++++++++------ .../MCPStreamableHttpClientTransport.swift | 127 ++++++++---------- .../Tools/DescribeTableToolTests.swift | 64 +++++++++ .../Protocol/Tools/DisconnectToolTests.swift | 42 ++++++ .../Protocol/Tools/ExportDataToolTests.swift | 83 ++++++++++++ .../Tools/FocusQueryTabToolTests.swift | 42 ++++++ .../Tools/GetConnectionStatusToolTests.swift | 42 ++++++ .../Protocol/Tools/GetTableDdlToolTests.swift | 64 +++++++++ .../Tools/ListConnectionsToolTests.swift | 27 ++++ .../Tools/ListDatabasesToolTests.swift | 42 ++++++ .../Tools/ListRecentTabsToolTests.swift | 27 ++++ .../Protocol/Tools/ListSchemasToolTests.swift | 42 ++++++ .../Protocol/Tools/ListTablesToolTests.swift | 42 ++++++ .../Tools/OpenConnectionWindowToolTests.swift | 42 ++++++ .../Tools/OpenTableTabToolTests.swift | 64 +++++++++ .../Tools/SearchQueryHistoryToolTests.swift | 45 +++++++ .../Tools/SwitchSchemaToolTests.swift | 64 +++++++++ 18 files changed, 855 insertions(+), 118 deletions(-) create mode 100644 TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/DisconnectToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/ExportDataToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/FocusQueryTabToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/GetConnectionStatusToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/ListDatabasesToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/ListRecentTabsToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/ListSchemasToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/ListTablesToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/OpenConnectionWindowToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/OpenTableTabToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/SearchQueryHistoryToolTests.swift create mode 100644 TableProTests/Core/MCP/Protocol/Tools/SwitchSchemaToolTests.swift diff --git a/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift b/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift index 80e8d6ee3..fa19fde32 100644 --- a/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift +++ b/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift @@ -48,14 +48,18 @@ public struct MCPStderrBridgeLogger: MCPBridgeLogger { case .warning: prefix = "[warn] " case .error: prefix = "[error] " } - writer.write(prefix + message + "\n") + let payload = prefix + message + "\n" + let target = writer + Task { + await target.write(payload) + } } } public struct MCPCompositeBridgeLogger: MCPBridgeLogger { - private let loggers: [MCPBridgeLogger] + private let loggers: [any MCPBridgeLogger] - public init(_ loggers: [MCPBridgeLogger]) { + public init(_ loggers: [any MCPBridgeLogger]) { self.loggers = loggers } @@ -66,8 +70,7 @@ public struct MCPCompositeBridgeLogger: MCPBridgeLogger { } } -private final class StderrWriter: @unchecked Sendable { - private let lock = NSLock() +private actor StderrWriter { private let handle: FileHandle init(handle: FileHandle = .standardError) { @@ -76,8 +79,6 @@ private final class StderrWriter: @unchecked Sendable { func write(_ string: String) { guard let data = string.data(using: .utf8) else { return } - lock.lock() - defer { lock.unlock() } handle.write(data) } } diff --git a/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift b/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift index 2b301f135..be1c795fa 100644 --- a/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift @@ -1,37 +1,33 @@ import Foundation -public final class MCPStdioMessageTransport: MCPMessageTransport, @unchecked Sendable { - public let inbound: AsyncThrowingStream +public actor MCPStdioMessageTransport: MCPMessageTransport { + nonisolated public let inbound: AsyncThrowingStream - private let continuation: AsyncThrowingStream.Continuation + nonisolated private let continuationBox: StreamContinuationBox private let writer: StdioWriter - private let errorLogger: MCPBridgeLogger? - private let stateLock = NSLock() + private let errorLogger: (any MCPBridgeLogger)? private var readerTask: Task? private var isClosed = false public init( stdin: FileHandle = .standardInput, stdout: FileHandle = .standardOutput, - errorLogger: MCPBridgeLogger? = nil + errorLogger: (any MCPBridgeLogger)? = nil ) { - self.errorLogger = errorLogger - writer = StdioWriter(handle: stdout) - - var capturedContinuation: AsyncThrowingStream.Continuation! - inbound = AsyncThrowingStream { continuation in - capturedContinuation = continuation + let box = StreamContinuationBox() + let stream = AsyncThrowingStream { continuation in + box.set(continuation) } - continuation = capturedContinuation + self.continuationBox = box + self.inbound = stream + self.writer = StdioWriter(handle: stdout) + self.errorLogger = errorLogger - startReader(stdin: stdin) + Task { await self.startReader(stdin: stdin) } } public func send(_ message: JsonRpcMessage) async throws { - stateLock.lock() - let closed = isClosed - stateLock.unlock() - if closed { + if isClosed { throw MCPTransportError.closed } @@ -50,49 +46,42 @@ public final class MCPStdioMessageTransport: MCPMessageTransport, @unchecked Sen } public func close() async { - stateLock.lock() if isClosed { - stateLock.unlock() return } isClosed = true let task = readerTask readerTask = nil - stateLock.unlock() - task?.cancel() - continuation.finish() + continuationBox.finish() } private func startReader(stdin: FileHandle) { - let continuation = continuation + if isClosed { + return + } + let box = continuationBox let logger = errorLogger let task = Task.detached(priority: .userInitiated) { [weak self] in - await Self.readLoop(stdin: stdin, continuation: continuation, logger: logger) + await Self.readLoop(stdin: stdin, box: box, logger: logger) await self?.finishStream() } - - stateLock.lock() readerTask = task - stateLock.unlock() } - private func finishStream() async { - stateLock.lock() + private func finishStream() { if isClosed { - stateLock.unlock() return } isClosed = true readerTask = nil - stateLock.unlock() - continuation.finish() + continuationBox.finish() } private static func readLoop( stdin: FileHandle, - continuation: AsyncThrowingStream.Continuation, - logger: MCPBridgeLogger? + box: StreamContinuationBox, + logger: (any MCPBridgeLogger)? ) async { var buffer = Data() do { @@ -101,7 +90,7 @@ public final class MCPStdioMessageTransport: MCPMessageTransport, @unchecked Sen return } if byte == 0x0A { - processLine(buffer, continuation: continuation, logger: logger) + processLine(buffer, box: box, logger: logger) buffer.removeAll(keepingCapacity: true) continue } @@ -109,19 +98,19 @@ public final class MCPStdioMessageTransport: MCPMessageTransport, @unchecked Sen } } catch { logger?.log(.error, "stdio read failed: \(error)") - continuation.finish(throwing: MCPTransportError.readFailed(detail: String(describing: error))) + box.finish(throwing: MCPTransportError.readFailed(detail: String(describing: error))) return } if !buffer.isEmpty { - processLine(buffer, continuation: continuation, logger: logger) + processLine(buffer, box: box, logger: logger) } } private static func processLine( _ raw: Data, - continuation: AsyncThrowingStream.Continuation, - logger: MCPBridgeLogger? + box: StreamContinuationBox, + logger: (any MCPBridgeLogger)? ) { var trimmed = raw if trimmed.last == 0x0D { @@ -133,7 +122,7 @@ public final class MCPStdioMessageTransport: MCPMessageTransport, @unchecked Sen do { let message = try JsonRpcCodec.decode(trimmed) - continuation.yield(message) + box.yield(message) } catch { logger?.log(.warning, "stdio: skipping malformed JSON-RPC line: \(error)") } @@ -152,3 +141,31 @@ private actor StdioWriter { try? handle.synchronize() } } + +final class StreamContinuationBox: Sendable { + nonisolated(unsafe) private var continuation: AsyncThrowingStream.Continuation? + private let lock = NSLock() + + func set(_ continuation: AsyncThrowingStream.Continuation) { + lock.withLock { + self.continuation = continuation + } + } + + func yield(_ message: JsonRpcMessage) { + lock.withLock { + continuation?.yield(message) + } + } + + func finish(throwing error: Error? = nil) { + lock.withLock { + if let error { + continuation?.finish(throwing: error) + } else { + continuation?.finish() + } + continuation = nil + } + } +} diff --git a/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift b/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift index be67cdb77..dcee9da4d 100644 --- a/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift @@ -24,30 +24,33 @@ public struct MCPStreamableHttpClientConfiguration: Sendable { } } -public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unchecked Sendable { - public let inbound: AsyncThrowingStream +public actor MCPStreamableHttpClientTransport: MCPMessageTransport { + nonisolated public let inbound: AsyncThrowingStream - private let continuation: AsyncThrowingStream.Continuation + nonisolated private let continuationBox: StreamContinuationBox private let configuration: MCPStreamableHttpClientConfiguration private let urlSession: URLSession - private let errorLogger: MCPBridgeLogger? - private let state: ClientState + private let errorLogger: (any MCPBridgeLogger)? private let writer: HttpWriter + private var sessionId: String? + private var isClosed = false + private var serverInitiatedStreamOpen = false + private var tasks: [Task] = [] public init( configuration: MCPStreamableHttpClientConfiguration, urlSession: URLSession? = nil, - errorLogger: MCPBridgeLogger? = nil + errorLogger: (any MCPBridgeLogger)? = nil ) { self.configuration = configuration self.errorLogger = errorLogger - state = ClientState() - var capturedContinuation: AsyncThrowingStream.Continuation! - inbound = AsyncThrowingStream { continuation in - capturedContinuation = continuation + let box = StreamContinuationBox() + let stream = AsyncThrowingStream { continuation in + box.set(continuation) } - continuation = capturedContinuation + self.continuationBox = box + self.inbound = stream if let urlSession { self.urlSession = urlSession @@ -67,7 +70,7 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche } public func send(_ message: JsonRpcMessage) async throws { - if await state.isClosed { + if isClosed { throw MCPTransportError.closed } @@ -83,36 +86,50 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche guard let self else { return } await self.dispatch(body: body, requestId: requestId) } - await state.trackTask(task) + trackTask(task) } public func openSseStream() async throws { - if await state.isClosed { + if isClosed { throw MCPTransportError.closed } - if await state.serverInitiatedStreamOpen { + if serverInitiatedStreamOpen { return } - await state.markServerInitiatedStreamOpen(true) + serverInitiatedStreamOpen = true let task: Task = Task { [weak self] in guard let self else { return } await self.runServerInitiatedStream() } - await state.trackTask(task) + trackTask(task) } public func close() async { - if await state.isClosed { + if isClosed { return } - await state.setClosed() - let tasks = await state.takeTasks() - for task in tasks { + isClosed = true + let pending = tasks + tasks.removeAll() + for task in pending { task.cancel() } urlSession.invalidateAndCancel() - continuation.finish() + continuationBox.finish() + } + + private func trackTask(_ task: Task) { + tasks.removeAll { $0.isCancelled } + tasks.append(task) + } + + private func setSessionId(_ value: String) { + sessionId = value + } + + private func currentSessionId() -> String? { + sessionId } private func dispatch(body: Data, requestId: JsonRpcId?) async { @@ -132,7 +149,7 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") request.setValue("Bearer \(configuration.bearerToken)", forHTTPHeaderField: "Authorization") - if let sessionId = await state.sessionId { + if let sessionId = currentSessionId() { request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") } @@ -141,7 +158,7 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche throw MCPTransportError.readFailed(detail: "non-HTTP response") } - await captureSessionIdIfPresent(from: httpResponse) + captureSessionIdIfPresent(from: httpResponse) let status = httpResponse.statusCode let contentType = headerValue(httpResponse, name: "Content-Type")?.lowercased() ?? "" @@ -168,7 +185,7 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche } let data = try await collectBytes(bytes) - await handleNonSuccessResponse( + handleNonSuccessResponse( status: status, headers: httpResponse, body: data, @@ -182,7 +199,7 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche request.httpMethod = "GET" request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.setValue("Bearer \(configuration.bearerToken)", forHTTPHeaderField: "Authorization") - if let sessionId = await state.sessionId { + if let sessionId = currentSessionId() { request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") } @@ -191,11 +208,11 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche errorLogger?.log(.warning, "server-initiated stream: non-HTTP response") return } - await captureSessionIdIfPresent(from: httpResponse) + captureSessionIdIfPresent(from: httpResponse) let status = httpResponse.statusCode guard (200..<300).contains(status) else { let body = try await collectBytes(bytes) - await handleNonSuccessResponse( + handleNonSuccessResponse( status: status, headers: httpResponse, body: body, @@ -254,7 +271,7 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche } do { let message = try JsonRpcCodec.decode(payload) - continuation.yield(message) + continuationBox.yield(message) } catch { errorLogger?.log(.warning, "SSE: skipping malformed JSON-RPC frame: \(error)") } @@ -263,12 +280,12 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche private func pushJsonBody(_ data: Data, fallbackId: JsonRpcId?) { do { let message = try JsonRpcCodec.decode(data) - continuation.yield(message) + continuationBox.yield(message) } catch { errorLogger?.log(.warning, "HTTP: malformed JSON-RPC body: \(error)") let synthetic = MCPProtocolError.parseError(detail: String(describing: error)) .toJsonRpcErrorResponse(id: fallbackId) - continuation.yield(.errorResponse(synthetic)) + continuationBox.yield(.errorResponse(synthetic)) } } @@ -277,7 +294,7 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche headers: HTTPURLResponse, body: Data, requestId: JsonRpcId? - ) async { + ) { if requestId == nil { errorLogger?.log(.warning, "HTTP \(status) for notification (no response will be emitted)") return @@ -285,11 +302,11 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche if !body.isEmpty, let parsed = try? JsonRpcCodec.decode(body) { if case .errorResponse = parsed { - continuation.yield(parsed) + continuationBox.yield(parsed) return } if case .successResponse = parsed { - continuation.yield(parsed) + continuationBox.yield(parsed) return } } @@ -297,7 +314,7 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche let challenge = headerValue(headers, name: "WWW-Authenticate") ?? "Bearer realm=\"TablePro\"" let protocolError = Self.protocolError(forStatus: status, body: body, challenge: challenge) let response = protocolError.toJsonRpcErrorResponse(id: requestId) - continuation.yield(.errorResponse(response)) + continuationBox.yield(.errorResponse(response)) } private func handleSendError(error: Error, requestId: JsonRpcId?) async { @@ -310,12 +327,12 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche } let protocolError = MCPProtocolError.internalError(detail: String(describing: error)) let response = protocolError.toJsonRpcErrorResponse(id: requestId) - continuation.yield(.errorResponse(response)) + continuationBox.yield(.errorResponse(response)) } - private func captureSessionIdIfPresent(from response: HTTPURLResponse) async { + private func captureSessionIdIfPresent(from response: HTTPURLResponse) { guard let value = headerValue(response, name: "Mcp-Session-Id") else { return } - await state.setSessionId(value) + setSessionId(value) } private func headerValue(_ response: HTTPURLResponse, name: String) -> String? { @@ -369,36 +386,6 @@ public final class MCPStreamableHttpClientTransport: MCPMessageTransport, @unche } } -private actor ClientState { - private(set) var sessionId: String? - private(set) var isClosed = false - private(set) var serverInitiatedStreamOpen = false - private var tasks: [Task] = [] - - func setSessionId(_ id: String) { - sessionId = id - } - - func setClosed() { - isClosed = true - } - - func markServerInitiatedStreamOpen(_ value: Bool) { - serverInitiatedStreamOpen = value - } - - func trackTask(_ task: Task) { - tasks.removeAll { $0.isCancelled } - tasks.append(task) - } - - func takeTasks() -> [Task] { - let copy = tasks - tasks.removeAll() - return copy - } -} - private actor HttpWriter { func serialize(_ work: @Sendable () async throws -> T) async throws -> T { try await work() @@ -407,9 +394,9 @@ private actor HttpWriter { private final class CertificatePinningDelegate: NSObject, URLSessionDelegate { private let expectedFingerprint: String - private let errorLogger: MCPBridgeLogger? + private let errorLogger: (any MCPBridgeLogger)? - init(expectedFingerprint: String, errorLogger: MCPBridgeLogger?) { + init(expectedFingerprint: String, errorLogger: (any MCPBridgeLogger)?) { self.expectedFingerprint = expectedFingerprint self.errorLogger = errorLogger } diff --git a/TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift new file mode 100644 index 000000000..104628384 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift @@ -0,0 +1,64 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("DescribeTableTool") +struct DescribeTableToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(DescribeTableTool.name == "describe_table") + #expect(DescribeTableTool.requiredScopes == [.toolsRead]) + let schema = DescribeTableTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["connection_id", "table"]) + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = DescribeTableTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["table": .string("users")]), + context: context, + services: services + ) + } + } + + @Test("Missing table returns invalidParams") + func missingTable() async throws { + let tool = DescribeTableTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["connection_id": .string(UUID().uuidString)]), + context: context, + services: services + ) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = DescribeTableTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string("not-a-uuid"), + "table": .string("users") + ]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/DisconnectToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/DisconnectToolTests.swift new file mode 100644 index 000000000..94868ab05 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/DisconnectToolTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("DisconnectTool") +struct DisconnectToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(DisconnectTool.name == "disconnect") + #expect(DisconnectTool.requiredScopes == [.toolsWrite]) + let schema = DisconnectTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["connection_id"]) + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = DisconnectTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call(arguments: .object([:]), context: context, services: services) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = DisconnectTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["connection_id": .string("not-a-uuid")]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/ExportDataToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ExportDataToolTests.swift new file mode 100644 index 000000000..9f43d26a9 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/ExportDataToolTests.swift @@ -0,0 +1,83 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("ExportDataTool") +struct ExportDataToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(ExportDataTool.name == "export_data") + #expect(ExportDataTool.requiredScopes == [.toolsRead]) + let schema = ExportDataTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["connection_id", "format"]) + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = ExportDataTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["format": .string("csv")]), + context: context, + services: services + ) + } + } + + @Test("Missing format returns invalidParams") + func missingFormat() async throws { + let tool = ExportDataTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["connection_id": .string(UUID().uuidString)]), + context: context, + services: services + ) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = ExportDataTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string("not-a-uuid"), + "format": .string("csv"), + "query": .string("SELECT 1") + ]), + context: context, + services: services + ) + } + } + + @Test("Neither query nor tables returns invalidParams") + func missingQueryAndTables() async throws { + let tool = ExportDataTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string(UUID().uuidString), + "format": .string("csv") + ]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/FocusQueryTabToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/FocusQueryTabToolTests.swift new file mode 100644 index 000000000..34a2edc6b --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/FocusQueryTabToolTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("FocusQueryTabTool") +struct FocusQueryTabToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(FocusQueryTabTool.name == "focus_query_tab") + #expect(FocusQueryTabTool.requiredScopes == [.toolsRead]) + let schema = FocusQueryTabTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["tab_id"]) + } + + @Test("Missing tab_id returns invalidParams") + func missingTabId() async throws { + let tool = FocusQueryTabTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call(arguments: .object([:]), context: context, services: services) + } + } + + @Test("Malformed tab_id returns invalidParams") + func malformedTabId() async throws { + let tool = FocusQueryTabTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["tab_id": .string("not-a-uuid")]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/GetConnectionStatusToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/GetConnectionStatusToolTests.swift new file mode 100644 index 000000000..4434a9f59 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/GetConnectionStatusToolTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("GetConnectionStatusTool") +struct GetConnectionStatusToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(GetConnectionStatusTool.name == "get_connection_status") + #expect(GetConnectionStatusTool.requiredScopes == [.toolsRead]) + let schema = GetConnectionStatusTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["connection_id"]) + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = GetConnectionStatusTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call(arguments: .object([:]), context: context, services: services) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = GetConnectionStatusTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["connection_id": .string("not-a-uuid")]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift new file mode 100644 index 000000000..716c5d7ce --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift @@ -0,0 +1,64 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("GetTableDdlTool") +struct GetTableDdlToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(GetTableDdlTool.name == "get_table_ddl") + #expect(GetTableDdlTool.requiredScopes == [.toolsRead]) + let schema = GetTableDdlTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["connection_id", "table"]) + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = GetTableDdlTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["table": .string("users")]), + context: context, + services: services + ) + } + } + + @Test("Missing table returns invalidParams") + func missingTable() async throws { + let tool = GetTableDdlTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["connection_id": .string(UUID().uuidString)]), + context: context, + services: services + ) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = GetTableDdlTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string("not-a-uuid"), + "table": .string("users") + ]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift new file mode 100644 index 000000000..d8625f549 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift @@ -0,0 +1,27 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("ListConnectionsTool") +struct ListConnectionsToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(ListConnectionsTool.name == "list_connections") + #expect(ListConnectionsTool.requiredScopes == [.toolsRead]) + let schema = ListConnectionsTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == []) + } + + @Test("Empty arguments returns a successful result") + func emptyArgumentsSucceed() async throws { + let tool = ListConnectionsTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + let result = try await tool.call(arguments: .object([:]), context: context, services: services) + #expect(result.isError == false) + #expect(result.content.isEmpty == false) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/ListDatabasesToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ListDatabasesToolTests.swift new file mode 100644 index 000000000..d0fb0fd9b --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/ListDatabasesToolTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("ListDatabasesTool") +struct ListDatabasesToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(ListDatabasesTool.name == "list_databases") + #expect(ListDatabasesTool.requiredScopes == [.toolsRead]) + let schema = ListDatabasesTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["connection_id"]) + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = ListDatabasesTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call(arguments: .object([:]), context: context, services: services) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = ListDatabasesTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["connection_id": .string("not-a-uuid")]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/ListRecentTabsToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ListRecentTabsToolTests.swift new file mode 100644 index 000000000..d37fc0995 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/ListRecentTabsToolTests.swift @@ -0,0 +1,27 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("ListRecentTabsTool") +struct ListRecentTabsToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(ListRecentTabsTool.name == "list_recent_tabs") + #expect(ListRecentTabsTool.requiredScopes == [.toolsRead]) + let schema = ListRecentTabsTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == []) + } + + @Test("Empty arguments returns a successful result") + func emptyArgumentsSucceed() async throws { + let tool = ListRecentTabsTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + let result = try await tool.call(arguments: .object([:]), context: context, services: services) + #expect(result.isError == false) + #expect(result.content.isEmpty == false) + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/ListSchemasToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ListSchemasToolTests.swift new file mode 100644 index 000000000..912b898cb --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/ListSchemasToolTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("ListSchemasTool") +struct ListSchemasToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(ListSchemasTool.name == "list_schemas") + #expect(ListSchemasTool.requiredScopes == [.toolsRead]) + let schema = ListSchemasTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["connection_id"]) + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = ListSchemasTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call(arguments: .object([:]), context: context, services: services) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = ListSchemasTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["connection_id": .string("not-a-uuid")]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/ListTablesToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ListTablesToolTests.swift new file mode 100644 index 000000000..d0ee51701 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/ListTablesToolTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("ListTablesTool") +struct ListTablesToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(ListTablesTool.name == "list_tables") + #expect(ListTablesTool.requiredScopes == [.toolsRead]) + let schema = ListTablesTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["connection_id"]) + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = ListTablesTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call(arguments: .object([:]), context: context, services: services) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = ListTablesTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["connection_id": .string("not-a-uuid")]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/OpenConnectionWindowToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/OpenConnectionWindowToolTests.swift new file mode 100644 index 000000000..870f3519b --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/OpenConnectionWindowToolTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("OpenConnectionWindowTool") +struct OpenConnectionWindowToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(OpenConnectionWindowTool.name == "open_connection_window") + #expect(OpenConnectionWindowTool.requiredScopes == [.toolsRead]) + let schema = OpenConnectionWindowTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["connection_id"]) + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = OpenConnectionWindowTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call(arguments: .object([:]), context: context, services: services) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = OpenConnectionWindowTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["connection_id": .string("not-a-uuid")]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/OpenTableTabToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/OpenTableTabToolTests.swift new file mode 100644 index 000000000..12e30a160 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/OpenTableTabToolTests.swift @@ -0,0 +1,64 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("OpenTableTabTool") +struct OpenTableTabToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(OpenTableTabTool.name == "open_table_tab") + #expect(OpenTableTabTool.requiredScopes == [.toolsRead]) + let schema = OpenTableTabTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["connection_id", "table_name"]) + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = OpenTableTabTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["table_name": .string("users")]), + context: context, + services: services + ) + } + } + + @Test("Missing table_name returns invalidParams") + func missingTableName() async throws { + let tool = OpenTableTabTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["connection_id": .string(UUID().uuidString)]), + context: context, + services: services + ) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = OpenTableTabTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string("not-a-uuid"), + "table_name": .string("users") + ]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/SearchQueryHistoryToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/SearchQueryHistoryToolTests.swift new file mode 100644 index 000000000..55264853d --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/SearchQueryHistoryToolTests.swift @@ -0,0 +1,45 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("SearchQueryHistoryTool") +struct SearchQueryHistoryToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(SearchQueryHistoryTool.name == "search_query_history") + #expect(SearchQueryHistoryTool.requiredScopes == [.toolsRead]) + let schema = SearchQueryHistoryTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["query"]) + } + + @Test("Missing query returns invalidParams") + func missingQuery() async throws { + let tool = SearchQueryHistoryTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call(arguments: .object([:]), context: context, services: services) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = SearchQueryHistoryTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([ + "query": .string("select"), + "connection_id": .string("not-a-uuid") + ]), + context: context, + services: services + ) + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/SwitchSchemaToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/SwitchSchemaToolTests.swift new file mode 100644 index 000000000..3f8493276 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/SwitchSchemaToolTests.swift @@ -0,0 +1,64 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("SwitchSchemaTool") +struct SwitchSchemaToolTests { + @Test("Tool exposes expected metadata") + func metadata() { + #expect(SwitchSchemaTool.name == "switch_schema") + #expect(SwitchSchemaTool.requiredScopes == [.toolsWrite]) + let schema = SwitchSchemaTool.inputSchema + #expect(schema["type"]?.stringValue == "object") + let required = schema["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required == ["connection_id", "schema"]) + } + + @Test("Missing connection_id returns invalidParams") + func missingConnectionId() async throws { + let tool = SwitchSchemaTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["schema": .string("public")]), + context: context, + services: services + ) + } + } + + @Test("Missing schema returns invalidParams") + func missingSchema() async throws { + let tool = SwitchSchemaTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object(["connection_id": .string(UUID().uuidString)]), + context: context, + services: services + ) + } + } + + @Test("Malformed connection_id returns invalidParams") + func malformedConnectionId() async throws { + let tool = SwitchSchemaTool() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let services = MCPToolServices(connectionBridge: MCPConnectionBridge(), authPolicy: MCPAuthPolicy()) + + await #expect(throws: MCPProtocolError.self) { + _ = try await tool.call( + arguments: .object([ + "connection_id": .string("not-a-uuid"), + "schema": .string("public") + ]), + context: context, + services: services + ) + } + } +} From be82b3de3178ad93a494b12671cf45ed62dc8b64 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 18:19:56 +0700 Subject: [PATCH 17/54] test(mcp): end-to-end bridge integration tests Spins up real MCPHttpServerTransport + MCPStreamableHttpClientTransport + MCPStdioMessageTransport in-process and exercises the full request/response path through a TestBridgeProxy that replicates production BridgeProxy logic (production class lives in the mcp-server target only, not visible to TableProTests via pbxproj membership exceptions). Four scenarios: happy-path init+tools/list, idle session eviction returns -32001 envelope, server emitting non-spec body is wrapped into a JSON-RPC error envelope by the client transport, malformed request returns a JSON-RPC error envelope (not the legacy plain {error:...} shape). Catches the class of wiring bugs that produced the 7 fix commits after the initial PR open. --- .../MCPBridgeIntegrationTests.swift | 731 ++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift diff --git a/TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift b/TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift new file mode 100644 index 000000000..37bf1edba --- /dev/null +++ b/TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift @@ -0,0 +1,731 @@ +import Foundation +import Network +@testable import TablePro +import XCTest + +final class MCPBridgeIntegrationTests: XCTestCase { + fileprivate static let mcpVersion = "2024-11-05" + fileprivate static let bearerToken = "integration-token" + + func testHappyPathInitializeAndToolsListFlowsThroughBridge() async throws { + let harness = try await BridgeHarness.start(authenticator: StubAlwaysAllowAuthenticator()) + defer { harness.shutdown() } + + let consumer = StubExchangeConsumer() + await consumer.start(transport: harness.serverTransport) { exchange in + switch exchange.message { + case .request(let request): + let response = JsonRpcMessage.successResponse( + JsonRpcSuccessResponse( + id: request.id, + result: .object(["echo": .string(request.method)]) + ) + ) + await exchange.responder.respond(response, sessionId: exchange.context.sessionId) + default: + await exchange.responder.respondError(.invalidRequest(detail: "unsupported"), requestId: nil) + } + } + defer { Task { await consumer.stop() } } + + let initRequest = JsonRpcMessage.request( + JsonRpcRequest(id: .number(1), method: "initialize", params: nil) + ) + try await harness.writeFromHost(initRequest) + + let firstResponse = try await harness.readNextResponse() + guard case .successResponse(let success) = firstResponse else { + XCTFail("Expected successResponse for initialize, got \(firstResponse)") + return + } + XCTAssertEqual(success.id, .number(1)) + XCTAssertEqual(success.result["echo"]?.stringValue, "initialize") + + let toolsRequest = JsonRpcMessage.request( + JsonRpcRequest(id: .number(2), method: "tools/list", params: nil) + ) + try await harness.writeFromHost(toolsRequest) + + let secondResponse = try await harness.readNextResponse() + guard case .successResponse(let toolsSuccess) = secondResponse else { + XCTFail("Expected successResponse for tools/list, got \(secondResponse)") + return + } + XCTAssertEqual(toolsSuccess.id, .number(2)) + XCTAssertEqual(toolsSuccess.result["echo"]?.stringValue, "tools/list") + } + + func testIdleSessionEvictionReturnsSessionNotFoundError() async throws { + let clock = MCPTestClock(start: Date(timeIntervalSince1970: 1_700_000_000)) + let policy = MCPSessionPolicy( + idleTimeout: .seconds(60), + maxSessions: 16, + cleanupInterval: .seconds(60) + ) + let harness = try await BridgeHarness.start( + authenticator: StubAlwaysAllowAuthenticator(), + clock: clock, + sessionPolicy: policy + ) + defer { harness.shutdown() } + + let consumer = StubExchangeConsumer() + await consumer.start(transport: harness.serverTransport) { exchange in + switch exchange.message { + case .request(let request): + let response = JsonRpcMessage.successResponse( + JsonRpcSuccessResponse(id: request.id, result: .object(["ok": .bool(true)])) + ) + await exchange.responder.respond(response, sessionId: exchange.context.sessionId) + default: + await exchange.responder.respondError(.invalidRequest(detail: "unsupported"), requestId: nil) + } + } + defer { Task { await consumer.stop() } } + + let initRequest = JsonRpcMessage.request( + JsonRpcRequest(id: .number(10), method: "initialize", params: nil) + ) + try await harness.writeFromHost(initRequest) + + let initResponse = try await harness.readNextResponse() + guard case .successResponse = initResponse else { + XCTFail("Expected initialize success, got \(initResponse)") + return + } + let initialSessionCount = await harness.sessionStore.count() + XCTAssertEqual(initialSessionCount, 1) + + await clock.advance(by: .seconds(120)) + await harness.sessionStore.runCleanupPass() + let postCleanupCount = await harness.sessionStore.count() + XCTAssertEqual(postCleanupCount, 0) + + let followUp = JsonRpcMessage.request( + JsonRpcRequest(id: .number(11), method: "tools/call", params: nil) + ) + try await harness.writeFromHost(followUp) + + let response = try await harness.readNextResponse() + guard case .errorResponse(let envelope) = response else { + XCTFail("Expected errorResponse, got \(response)") + return + } + XCTAssertEqual(envelope.id, .number(11)) + XCTAssertEqual(envelope.error.code, JsonRpcErrorCode.sessionNotFound) + } + + func testServerReturning404WithGarbageBodyIsWrappedAsJsonRpcError() async throws { + let badServer = try await BadHttpServer.start { _ in + BadHttpResponse( + status: 404, + headers: [("Content-Type", "application/json")], + body: Data("{\"error\":\"Session not found\"}".utf8) + ) + } + defer { badServer.stop() } + + guard let url = URL(string: "http://127.0.0.1:\(badServer.port)/mcp") else { + XCTFail("Failed to build URL") + return + } + let configuration = MCPStreamableHttpClientConfiguration( + endpoint: url, + bearerToken: Self.bearerToken, + tlsCertFingerprint: nil, + requestTimeout: .seconds(5), + serverInitiatedStream: false + ) + let client = MCPStreamableHttpClientTransport(configuration: configuration, errorLogger: nil) + defer { Task { await client.close() } } + + let request = JsonRpcMessage.request( + JsonRpcRequest(id: .number(42), method: "tools/list", params: nil) + ) + try await client.send(request) + + let received = try await Self.firstInbound(of: client, timeout: 3.0) + guard case .errorResponse(let envelope) = received else { + XCTFail("Expected errorResponse, got \(received)") + return + } + XCTAssertEqual(envelope.id, .number(42)) + XCTAssertEqual(envelope.error.code, JsonRpcErrorCode.sessionNotFound) + + let encoded = try JsonRpcCodec.encode(received) + let roundTripped = try JsonRpcCodec.decode(encoded) + XCTAssertEqual(roundTripped, received) + } + + func testMalformedRequestReturnsValidJsonRpcErrorEnvelope() async throws { + let harness = try await BridgeHarness.start(authenticator: StubAlwaysAllowAuthenticator()) + defer { harness.shutdown() } + + let consumer = StubExchangeConsumer() + await consumer.start(transport: harness.serverTransport) { exchange in + await exchange.responder.respondError(.invalidRequest(detail: "should-not-reach"), requestId: nil) + } + defer { Task { await consumer.stop() } } + + guard let url = URL(string: "http://127.0.0.1:\(harness.serverPort)/mcp") else { + XCTFail("Failed to build URL") + return + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(Self.mcpVersion, forHTTPHeaderField: "mcp-protocol-version") + request.setValue("Bearer \(Self.bearerToken)", forHTTPHeaderField: "Authorization") + request.httpBody = Data("{\"not\":\"json-rpc\"}".utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) + + XCTAssertGreaterThanOrEqual(httpResponse.statusCode, 400) + XCTAssertLessThan(httpResponse.statusCode, 500) + XCTAssertFalse(data.isEmpty, "Server must return a body for malformed requests") + + let decoded = try JsonRpcCodec.decode(data) + guard case .errorResponse(let envelope) = decoded else { + XCTFail("Expected JSON-RPC errorResponse envelope, got \(decoded)") + return + } + XCTAssertTrue( + envelope.error.code == JsonRpcErrorCode.invalidRequest + || envelope.error.code == JsonRpcErrorCode.parseError + || envelope.error.code == JsonRpcErrorCode.methodNotFound, + "Unexpected error code \(envelope.error.code)" + ) + + let plainErrorShape = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + if let asObject = plainErrorShape { + XCTAssertNotNil(asObject["jsonrpc"], "Body must include jsonrpc field; got plain dict \(asObject)") + XCTAssertNotNil(asObject["error"], "Body must include error field") + } + } + + private static func firstInbound( + of transport: MCPStreamableHttpClientTransport, + timeout: TimeInterval + ) async throws -> JsonRpcMessage { + try await withThrowingTaskGroup(of: JsonRpcMessage?.self) { group in + group.addTask { + var iterator = transport.inbound.makeAsyncIterator() + return try await iterator.next() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return nil + } + guard let result = try await group.next(), let value = result else { + group.cancelAll() + throw IntegrationTestError.timeout + } + group.cancelAll() + return value + } + } +} + +private enum IntegrationTestError: Error { + case timeout + case serverDidNotStart + case readClosed +} + +private struct PipePair { + let hostInput: FileHandle + let bridgeStdin: FileHandle + let bridgeStdout: FileHandle + let hostOutput: FileHandle + + let stdinPipe: Pipe + let stdoutPipe: Pipe + + static func make() -> PipePair { + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + return PipePair( + hostInput: stdinPipe.fileHandleForWriting, + bridgeStdin: stdinPipe.fileHandleForReading, + bridgeStdout: stdoutPipe.fileHandleForWriting, + hostOutput: stdoutPipe.fileHandleForReading, + stdinPipe: stdinPipe, + stdoutPipe: stdoutPipe + ) + } + + func closeAll() { + try? hostInput.close() + try? bridgeStdin.close() + try? bridgeStdout.close() + try? hostOutput.close() + } +} + +private final class IntegrationBridgeLogger: MCPBridgeLogger, @unchecked Sendable { + func log(_ level: MCPBridgeLogLevel, _ message: String) {} +} + +private actor TestBridgeProxy { + private let host: any MCPMessageTransport + private let upstream: any MCPMessageTransport + private let logger: any MCPBridgeLogger + private var task: Task? + + init(host: any MCPMessageTransport, upstream: any MCPMessageTransport, logger: any MCPBridgeLogger) { + self.host = host + self.upstream = upstream + self.logger = logger + } + + func start() { + task = Task { [host, upstream, logger] in + await withTaskGroup(of: Void.self) { group in + group.addTask { + await Self.forward(from: host, to: upstream, direction: "host→upstream", logger: logger) + } + group.addTask { + await Self.forward(from: upstream, to: host, direction: "upstream→host", logger: logger) + } + await group.waitForAll() + } + } + } + + func stop() { + task?.cancel() + task = nil + } + + private static func forward( + from source: any MCPMessageTransport, + to destination: any MCPMessageTransport, + direction: String, + logger: any MCPBridgeLogger + ) async { + do { + for try await message in source.inbound { + do { + try await destination.send(message) + } catch { + logger.log(.warning, "[\(direction)] send failed: \(error.localizedDescription)") + } + } + logger.log(.info, "[\(direction)] inbound stream closed") + } catch { + logger.log(.error, "[\(direction)] inbound failed: \(error.localizedDescription)") + } + await destination.close() + } +} + +private actor LineQueue { + private var pending: [Data] = [] + private var waiters: [CheckedContinuation] = [] + private var finished = false + + func push(_ line: Data) { + if let waiter = waiters.first { + waiters.removeFirst() + waiter.resume(returning: line) + return + } + pending.append(line) + } + + func finish() { + finished = true + let toResume = waiters + waiters.removeAll() + for waiter in toResume { + waiter.resume(returning: nil) + } + } + + func next() async -> Data? { + if !pending.isEmpty { + return pending.removeFirst() + } + if finished { + return nil + } + return await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + +private final class BridgeHarness: @unchecked Sendable { + let serverTransport: MCPHttpServerTransport + let sessionStore: MCPSessionStore + let serverPort: UInt16 + let clientTransport: MCPStreamableHttpClientTransport + let stdioTransport: MCPStdioMessageTransport + private let proxy: TestBridgeProxy + private let pipes: PipePair + private let lineQueue = LineQueue() + private var readerTask: Task? + private let stateLock = NSLock() + + private init( + serverTransport: MCPHttpServerTransport, + sessionStore: MCPSessionStore, + serverPort: UInt16, + clientTransport: MCPStreamableHttpClientTransport, + stdioTransport: MCPStdioMessageTransport, + proxy: TestBridgeProxy, + pipes: PipePair + ) { + self.serverTransport = serverTransport + self.sessionStore = sessionStore + self.serverPort = serverPort + self.clientTransport = clientTransport + self.stdioTransport = stdioTransport + self.proxy = proxy + self.pipes = pipes + } + + static func start( + authenticator: any MCPAuthenticator, + clock: any MCPClock = MCPSystemClock(), + sessionPolicy: MCPSessionPolicy = MCPSessionPolicy( + idleTimeout: .seconds(900), + maxSessions: 16, + cleanupInterval: .seconds(60) + ) + ) async throws -> BridgeHarness { + let store = MCPSessionStore(policy: sessionPolicy, clock: clock) + let configuration = MCPHttpServerConfiguration.loopback(port: 0) + let serverTransport = MCPHttpServerTransport( + configuration: configuration, + sessionStore: store, + authenticator: authenticator, + clock: clock + ) + + let stateStream = serverTransport.listenerState + let stateTask = Task { + for await state in stateStream { + if case .running(let port) = state { + return port + } + if case .failed = state { + return nil + } + } + return nil + } + + try await serverTransport.start() + guard let port = await stateTask.value, port != 0 else { + await serverTransport.stop() + throw IntegrationTestError.serverDidNotStart + } + + guard let url = URL(string: "http://127.0.0.1:\(port)/mcp") else { + await serverTransport.stop() + throw IntegrationTestError.serverDidNotStart + } + let logger = IntegrationBridgeLogger() + let clientConfig = MCPStreamableHttpClientConfiguration( + endpoint: url, + bearerToken: MCPBridgeIntegrationTests.bearerToken, + tlsCertFingerprint: nil, + requestTimeout: .seconds(5), + serverInitiatedStream: false + ) + let clientTransport = MCPStreamableHttpClientTransport( + configuration: clientConfig, + errorLogger: logger + ) + + let pipes = PipePair.make() + let stdioTransport = MCPStdioMessageTransport( + stdin: pipes.bridgeStdin, + stdout: pipes.bridgeStdout, + errorLogger: logger + ) + + let proxy = TestBridgeProxy(host: stdioTransport, upstream: clientTransport, logger: logger) + await proxy.start() + + let harness = BridgeHarness( + serverTransport: serverTransport, + sessionStore: store, + serverPort: port, + clientTransport: clientTransport, + stdioTransport: stdioTransport, + proxy: proxy, + pipes: pipes + ) + harness.startReader() + return harness + } + + func writeFromHost(_ message: JsonRpcMessage) async throws { + let line = try JsonRpcCodec.encodeLine(message) + try pipes.hostInput.write(contentsOf: line) + } + + func readNextResponse(timeout: TimeInterval = 4.0) async throws -> JsonRpcMessage { + let line = try await readNextLine(timeout: timeout) + return try JsonRpcCodec.decode(line) + } + + private func readNextLine(timeout: TimeInterval) async throws -> Data { + let queue = lineQueue + return try await withThrowingTaskGroup(of: Data?.self) { group in + group.addTask { + await queue.next() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return nil + } + guard let first = try await group.next(), let value = first else { + group.cancelAll() + throw IntegrationTestError.timeout + } + group.cancelAll() + return value + } + } + + fileprivate func startReader() { + stateLock.lock() + if readerTask != nil { + stateLock.unlock() + return + } + let handle = pipes.hostOutput + let queue = lineQueue + readerTask = Task.detached(priority: .userInitiated) { + var buffer = Data() + do { + for try await byte in handle.bytes { + if Task.isCancelled { return } + if byte == 0x0A { + var line = buffer + buffer.removeAll(keepingCapacity: true) + if line.last == 0x0D { + line.removeLast() + } + if !line.isEmpty { + await queue.push(line) + } + } else { + buffer.append(byte) + } + } + } catch { + // pipe closed or read error; finish the queue + } + await queue.finish() + } + stateLock.unlock() + } + + func shutdown() { + stateLock.lock() + readerTask?.cancel() + readerTask = nil + stateLock.unlock() + let queue = lineQueue + Task { await queue.finish() } + Task { await proxy.stop() } + Task { await stdioTransport.close() } + Task { await clientTransport.close() } + Task { await serverTransport.stop() } + pipes.closeAll() + } +} + +private struct BadHttpResponse: Sendable { + let status: Int + let headers: [(String, String)] + let body: Data +} + +private actor BadHttpServerState { + var responder: (@Sendable (Data) -> BadHttpResponse)? + + func setResponder(_ responder: @escaping @Sendable (Data) -> BadHttpResponse) { + self.responder = responder + } + + func respond(_ data: Data) -> BadHttpResponse { + responder?(data) ?? BadHttpResponse(status: 500, headers: [], body: Data()) + } +} + +private final class BadHttpServer: @unchecked Sendable { + private let state = BadHttpServerState() + private var listener: NWListener? + private let lock = NSLock() + private var assignedPort: UInt16 = 0 + private var connections: [NWConnection] = [] + + var port: UInt16 { + lock.lock() + defer { lock.unlock() } + return assignedPort + } + + static func start(_ responder: @escaping @Sendable (Data) -> BadHttpResponse) async throws -> BadHttpServer { + let server = BadHttpServer() + await server.state.setResponder(responder) + try await server.startListener() + return server + } + + private func startListener() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + let listener = try NWListener(using: params) + lock.lock() + self.listener = listener + lock.unlock() + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + if let port = listener.port?.rawValue { + self.lock.lock() + self.assignedPort = port + self.lock.unlock() + } + continuation.resume() + case .failed(let error): + continuation.resume(throwing: error) + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection) + } + listener.start(queue: .global(qos: .userInitiated)) + } catch { + continuation.resume(throwing: error) + } + } + } + + func stop() { + lock.lock() + let listener = self.listener + let connections = self.connections + self.listener = nil + self.connections = [] + lock.unlock() + listener?.cancel() + for connection in connections { + connection.cancel() + } + } + + private func handle(_ connection: NWConnection) { + lock.lock() + connections.append(connection) + lock.unlock() + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.readLoop(connection: connection, accumulated: Data()) + case .failed, .cancelled: + break + default: + break + } + } + connection.start(queue: .global(qos: .userInitiated)) + } + + private func readLoop(connection: NWConnection, accumulated: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1_024) { [weak self] data, _, isComplete, _ in + guard let self else { return } + var buffer = accumulated + if let data { + buffer.append(data) + } + + if let bodyStart = Self.findHeaderEnd(buffer) { + let contentLength = Self.contentLength(buffer.prefix(bodyStart)) + let bodyAvailable = buffer.count - bodyStart + if bodyAvailable < contentLength { + if isComplete { + connection.cancel() + return + } + self.readLoop(connection: connection, accumulated: buffer) + return + } + let body = buffer.subdata(in: bodyStart..<(bodyStart + contentLength)) + Task { + let response = await self.state.respond(body) + let raw = Self.serialize(response) + connection.send(content: raw, completion: .contentProcessed { _ in + connection.cancel() + }) + } + return + } + + if isComplete { + connection.cancel() + return + } + self.readLoop(connection: connection, accumulated: buffer) + } + } + + private static func findHeaderEnd(_ data: Data) -> Int? { + guard let range = data.range(of: Data("\r\n\r\n".utf8)) else { return nil } + return range.upperBound + } + + private static func contentLength(_ headerData: Data) -> Int { + guard let headerString = String(data: headerData, encoding: .utf8) else { return 0 } + for line in headerString.components(separatedBy: "\r\n") { + guard let colon = line.firstIndex(of: ":") else { continue } + let key = line[line.startIndex.. Data { + var output = "HTTP/1.1 \(response.status) \(reasonPhrase(for: response.status))\r\n" + var headers = response.headers + if !headers.contains(where: { $0.0.lowercased() == "content-length" }) { + headers.append(("Content-Length", "\(response.body.count)")) + } + if !headers.contains(where: { $0.0.lowercased() == "connection" }) { + headers.append(("Connection", "close")) + } + for (key, value) in headers { + output.append("\(key): \(value)\r\n") + } + output.append("\r\n") + var data = Data(output.utf8) + data.append(response.body) + return data + } + + private static func reasonPhrase(for status: Int) -> String { + switch status { + case 200: return "OK" + case 400: return "Bad Request" + case 401: return "Unauthorized" + case 404: return "Not Found" + case 500: return "Internal Server Error" + default: return "Status" + } + } +} From 60c7ba8bb2263c663a29555d57f5337b63c150c9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 18:25:44 +0700 Subject: [PATCH 18/54] fix(mcp): restore /v1/integrations/exchange HTTP route for Raycast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 deleted Routes/IntegrationsExchangeHandler.swift on the assumption that the bridge runs in-process with the app, so an HTTP route for pairing-code exchange was redundant. That assumption is wrong: the Raycast extension is a separate Node.js process that POSTs to /v1/integrations/exchange to redeem a pairing code for a bearer token, and any other future external integration would do the same. Restore the route inline in MCPHttpServerTransport.dispatch — POST to /v1/integrations/exchange parses {code, code_verifier}, calls MCPPairing Service.shared.exchange on MainActor, returns {token} on 200 or a plain {error: ...} JSON body on 4xx/5xx. Adds writePlainJsonResponse + writePlainJsonError helpers on HttpConnectionContext for the non-MCP shape this endpoint uses. --- .../Transport/MCPHttpServerTransport.swift | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift index ac44ecc45..5c8bfec61 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -290,6 +290,11 @@ public actor MCPHttpServerTransport { let clientAddress: MCPClientAddress = await context.clientAddress() let now = await clock.now() + if head.method == .post, stripQueryString(head.path) == "/v1/integrations/exchange" { + await handleIntegrationsExchange(body: body, context: context) + return + } + switch head.method { case .options: await context.writeOptions204() @@ -314,6 +319,71 @@ public actor MCPHttpServerTransport { } } + private func handleIntegrationsExchange(body: Data, context: HttpConnectionContext) async { + struct ExchangeBody: Decodable { + let code: String + let codeVerifier: String + enum CodingKeys: String, CodingKey { + case code + case codeVerifier = "code_verifier" + } + } + struct ExchangeResponse: Encodable { + let token: String + } + + let parsed: ExchangeBody + do { + parsed = try JSONDecoder().decode(ExchangeBody.self, from: body) + } catch { + await context.writePlainJsonError(status: .badRequest, message: "Invalid JSON body") + await context.cancel() + return + } + + guard !parsed.code.isEmpty, !parsed.codeVerifier.isEmpty else { + await context.writePlainJsonError(status: .badRequest, message: "Missing code or code_verifier") + await context.cancel() + return + } + + let exchange = PairingExchange(code: parsed.code, verifier: parsed.codeVerifier) + let outcome: Result = await MainActor.run { + do { + return .success(try MCPPairingService.shared.exchange(exchange)) + } catch { + return .failure(error) + } + } + + switch outcome { + case .success(let token): + let payload = (try? JSONEncoder().encode(ExchangeResponse(token: token))) ?? Data() + await context.writePlainJsonResponse(status: .ok, body: payload) + await context.cancel() + case .failure(let error): + let mapped = Self.mapExchangeError(error) + await context.writePlainJsonError(status: mapped.status, message: mapped.message) + await context.cancel() + } + } + + private static func mapExchangeError(_ error: Error) -> (status: HttpStatus, message: String) { + guard let domainError = error as? MCPDataLayerError else { + return (.internalServerError, "Internal error") + } + switch domainError { + case .notFound: + return (.notFound, "Pairing code not found") + case .expired: + return (HttpStatus(code: 410, reasonPhrase: "Gone"), "Pairing code expired") + case .forbidden: + return (.forbidden, "Challenge mismatch") + default: + return (.internalServerError, "Internal error") + } + } + private func handleGetMcp( head: HttpRequestHead, body: Data, @@ -760,6 +830,24 @@ actor HttpConnectionContext { await send(payload) } + func writePlainJsonResponse(status: HttpStatus, body: Data) async { + if cancelled { return } + var headers: [(String, String)] = [ + ("Content-Type", "application/json"), + ("Connection", "close") + ] + headers.append(contentsOf: MCPCorsHeaders.standard) + let head = HttpResponseHead(status: status, headers: HttpHeaders(headers)) + let payload = HttpResponseEncoder.encode(head, body: body) + await send(payload) + } + + func writePlainJsonError(status: HttpStatus, message: String) async { + struct ErrorBody: Encodable { let error: String } + let payload = (try? JSONEncoder().encode(ErrorBody(error: message))) ?? Data() + await writePlainJsonResponse(status: status, body: payload) + } + func writeOptions204() async { if cancelled { return } var headers: [(String, String)] = [("Connection", "close")] From 2babe1048a8add9893eab4a34de49a860a637ec2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 18:29:51 +0700 Subject: [PATCH 19/54] docs(mcp): align external-api docs with the rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcp-tools.mdx — fix scope mismatches across tools (disconnect, switch_*, confirm_destructive_operation, navigation tools), document the streaming progress flow via _meta.progressToken, replace the HTTP-status error table with the JsonRpcErrorCode + HTTP status pairs that match JsonRpcErrorCode.swift / MCPProtocolError.swift, fix execute_query and export_data shapes to match what the actual handlers return, fix describe_table's database/schema defaulting note, add the spec-compliant 404/-32001 'Session not found' paragraph. mcp-clients.mdx — drop the spurious '--transport stdio' flag from the Claude Code command (stdio is the default). Add 404 and 429 entries to the verification troubleshooting list. Document the WWW-Authenticate header on 401. mcp-resources.mdx — new Discovery section for resources/list and resources/templates/list (latter was undocumented). Add the {contents: [{uri,mimeType,text}]} envelope shape that resources/read returns. tokens.mdx — rewrite TokenPermissions -> Set mapping to match MCPBearerTokenAuthenticator.mcpScopes. Replace the per-IP escalating lockout description with the new flat policy from MCPRateLimitPolicy (5 fails / 60s window / 5min lockout / (client_address, principal_ fingerprint) keyed). url-scheme.mdx — remove the 'token=' query parameter described against DeeplinkParser.parseQuery, which doesn't read it. Add the 51,200-char SQL cap and a pointer to MCP execute_query for headless work. versioning.mdx — note protocolVersion: 2025-03-26 from InitializeHandler. features/mcp.mdx — replace 5-minute escalating lockout with the new policy, matching tokens.mdx. --- docs/external-api/mcp-clients.mdx | 8 +- docs/external-api/mcp-resources.mdx | 35 +- docs/external-api/mcp-tools.mdx | 82 ++- docs/external-api/tokens.mdx | 43 +- docs/external-api/url-scheme.mdx | 12 +- docs/external-api/versioning.mdx | 2 + docs/features/mcp.mdx | 2 +- docs/refactor/hig-audit/00-overview.md | 148 ++++ docs/refactor/hig-audit/01-menus-shortcuts.md | 648 ++++++++++++++++++ .../hig-audit/02-windows-interactions.md | 394 +++++++++++ docs/refactor/hig-audit/03-chrome-visual.md | 388 +++++++++++ docs/refactor/hig-audit/04-system-document.md | 238 +++++++ 12 files changed, 1933 insertions(+), 67 deletions(-) create mode 100644 docs/refactor/hig-audit/00-overview.md create mode 100644 docs/refactor/hig-audit/01-menus-shortcuts.md create mode 100644 docs/refactor/hig-audit/02-windows-interactions.md create mode 100644 docs/refactor/hig-audit/03-chrome-visual.md create mode 100644 docs/refactor/hig-audit/04-system-document.md diff --git a/docs/external-api/mcp-clients.mdx b/docs/external-api/mcp-clients.mdx index 9e6464c73..c0bffa520 100644 --- a/docs/external-api/mcp-clients.mdx +++ b/docs/external-api/mcp-clients.mdx @@ -44,10 +44,10 @@ Restart Claude Desktop. Open a new chat, click the connectors icon below the inp Use the `claude mcp add` CLI: ```bash -claude mcp add --transport stdio tablepro -- /Applications/TablePro.app/Contents/MacOS/tablepro-mcp +claude mcp add tablepro -- /Applications/TablePro.app/Contents/MacOS/tablepro-mcp ``` -The double dash separates Claude Code's flags from the command it runs. Verify with `claude mcp list`. +The double dash separates Claude Code's flags from the command it runs. stdio is the default transport, so no `--transport` flag is needed. Verify with `claude mcp list`. ## Cursor @@ -200,8 +200,10 @@ After configuring a client, the fastest check is to ask it to list TablePro tool If the call fails, the response code tells you which layer rejected it: - **stdio process exits immediately**: TablePro is not running, or you are on a build older than 0.37. Open TablePro and re-launch the client. -- **`401 Unauthorized`**: the bridge token is stale. Quit and reopen TablePro to regenerate the handshake. +- **`401 Unauthorized`** (`WWW-Authenticate: Bearer ...`): the bridge token is stale. Quit and reopen TablePro to regenerate the handshake. - **`403 Forbidden`**: the connection's `externalAccess` is `blocked` or `readOnly`, or the token's allowlist excludes it. Open the connection editor in TablePro and adjust under **External Access**. +- **`404 Session not found`** (JSON-RPC code `-32001`): the session expired (idle timeout is 15 minutes) or the server restarted. Per the MCP spec, drop the cached `Mcp-Session-Id` and start a new `initialize` handshake. Compliant clients (Claude Desktop 0.7+, Cursor, Cline) do this automatically. +- **`429 Too Many Requests`**: 5 failed auth attempts within 60 seconds against the same `(client_address, principal)` pair triggered a 5-minute lockout. Wait it out or restart TablePro to clear the bucket. ## Troubleshooting diff --git a/docs/external-api/mcp-resources.mdx b/docs/external-api/mcp-resources.mdx index 13b28545e..fa3797f85 100644 --- a/docs/external-api/mcp-resources.mdx +++ b/docs/external-api/mcp-resources.mdx @@ -9,6 +9,31 @@ Resources are read-only views of TablePro state. AI clients use them to discover URIs use the `tablepro://` scheme inside the MCP transport. Do not confuse them with shell-level [URL scheme deep links](/external-api/url-scheme). +## Discovery + +Two MCP methods enumerate resources: + +- `resources/list` returns the static `tablepro://connections` resource plus a schema and history entry for each currently connected database. +- `resources/templates/list` returns the URI templates for `tablepro://connections/{id}/schema` and `tablepro://connections/{id}/history`, so clients can construct a URL for any connection without waiting for it to be open. + +## Response envelope + +`resources/read` wraps the resource payload in the MCP standard envelope: + +```json +{ + "contents": [ + { + "uri": "tablepro://connections", + "mimeType": "application/json", + "text": "{...JSON payload below as a string...}" + } + ] +} +``` + +The shapes documented below are what you get after parsing `text` as JSON. + ## `tablepro://connections` All saved connections with their current session state. @@ -107,7 +132,9 @@ Recent query history for a connection. ## Errors -| Code | Meaning | -|------|---------| -| `403` | Token allowlist rejects the connection, or `externalAccess` is `blocked`. | -| `404` | Connection not found. | +| JSON-RPC code | HTTP status | Meaning | +|---------------|-------------|---------| +| `-32602` | 200 | Invalid params (malformed URI, missing `uri`, bad UUID, connection not active). | +| `-32601` | 404 | Unknown resource URI (e.g. `tablepro://connections/{id}/foo`). | +| `-32004` | 404 | Resource not found in the data layer. | +| `-32007` | 403 | Token allowlist rejects the connection, or `externalAccess` is `blocked`. | diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index 988072906..adc5af82b 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -11,10 +11,10 @@ The MCP server exposes tools and resources over JSON-RPC. The tools are grouped The same tool catalog is available over two transports: -- **HTTP**: `http://127.0.0.1:/mcp` (port from the handshake file). Bearer token in `Authorization` header. +- **HTTP**: MCP Streamable HTTP at `http://127.0.0.1:/mcp` (port from the handshake file). POST for JSON-RPC requests, GET for the SSE stream that carries server-initiated notifications. Bearer token in `Authorization` header. - **stdio**: bundled `tablepro-mcp` CLI bridges stdio JSON-RPC to localhost HTTP. No token needed because the bridge reuses the in-app handshake. -See [MCP Clients](/external-api/mcp-clients) for stdio config snippets. +The server reports `protocolVersion: "2025-03-26"` from `initialize`. See [MCP Clients](/external-api/mcp-clients) for stdio config snippets. ## Scope and access matrix @@ -90,9 +90,9 @@ Close a connection. **Input**: `{ "connection_id": "..." }` -**Output**: empty object on success. +**Output**: `{ "status": "disconnected" }` on success. -**Scope**: `readOnly`. +**Scope**: `readWrite`. ### `get_connection_status` @@ -177,7 +177,7 @@ Columns, indexes, foreign keys, primary key, DDL. } ``` -`schema` is optional. The current database is used unless the connection was first switched with `switch_database`. +`schema` is optional. The connection's current schema is used when omitted. To target a different database, call `switch_database` first. **Output**: @@ -229,7 +229,7 @@ Just the `CREATE TABLE` statement. ### `execute_query` -Run a SQL query. +Execute a SQL query. All queries are subject to the connection's safe mode policy. DROP, TRUNCATE, and ALTER...DROP must use `confirm_destructive_operation`. **Input**: @@ -244,7 +244,7 @@ Run a SQL query. } ``` -`max_rows` defaults to 500, max 10,000. `timeout_seconds` defaults to 30, max 300. Single-statement queries only. Query size cap is 100 KB. +Defaults for `max_rows` and `timeout_seconds` come from **Settings > Integrations > MCP Configuration** (default row limit, query timeout). `max_rows` is clamped to the configured maximum (default 10,000). `timeout_seconds` is clamped to 1-300. Single-statement queries only. Query size cap is 100 KB. `database` and `schema` are optional; when present, the tool calls `switch_database` and/or `switch_schema` before executing. **Output**: @@ -269,6 +269,8 @@ Run a SQL query. Safe Mode rules apply on top. A connection in Safe Mode `readOnly` returns `403` for any write SQL. +**Streaming progress**: pass `_meta.progressToken` in the request and the server sends `notifications/progress` events on the SSE channel as the query moves through "Connecting", "Executing", "Formatting result", and "Done". Clients that don't include a token get the final response only. + ### `confirm_destructive_operation` Run a DROP, TRUNCATE, or ALTER...DROP after a typed confirmation. @@ -287,7 +289,7 @@ The confirmation phrase is fixed: `I understand this is irreversible`. Anything **Output**: same shape as `execute_query`. -**Scope**: `fullAccess`. +**Scope**: `readWrite` or `fullAccess` (both grant the `tools:write` MCP scope). The connection's external access must also permit writes; a `readOnly` connection rejects destructive operations even with a matching token. ### `export_data` @@ -304,9 +306,9 @@ Export query or table data as CSV, JSON, or SQL. } ``` -`format` is one of `csv`, `json`, `sql`. `max_rows` defaults to 50,000, max 100,000. Provide either `tables` or `query`. Pass `output_path` to write to disk instead of returning data inline. +`format` is one of `csv`, `json`, `sql`. `max_rows` defaults to 50,000, max 100,000. Provide either `tables` or `query`. Table names accept letters, digits, underscore, and `.` for schema-qualified names. Pass `output_path` to write to disk instead of returning data inline; the path must resolve inside the user's `~/Downloads` directory or the request is rejected with `400`. -**Output**: an envelope with one entry per query/table exported. Each entry has the export label and either inline data or the file path. Provide `output_path` in the request to receive a file-path response. +**Output**: when `output_path` is set, returns `{ "path": "...", "rows_exported": N }`. Otherwise returns the export inline. A single export returns `{ "label": "...", "format": "csv", "row_count": N, "data": "..." }`. Multiple exports (multi-table requests) return `{ "exports": [ { "label": "...", "format": "csv", "row_count": N, "data": "..." }, ... ] }`. **Scope**: `readOnly`. @@ -316,15 +318,15 @@ Export query or table data as CSV, JSON, or SQL. **Output**: `{ "status": "switched", "current_database": "analytics" }` or `{ "status": "switched", "current_schema": "reporting" }` -**Scope**: `readOnly`. +**Scope**: `readWrite` (mutates session state). ## Navigation tools -These mutate UI state in the running TablePro app: opening tabs, focusing windows. They require `readWrite` scope because the user sees the result. +These open or focus tabs and windows in the running TablePro app. They require `readOnly` scope and respect the connection allowlist; tabs from `externalAccess: blocked` connections are filtered out. ### `open_connection_window` -Open a connection in TablePro and bring its window to front. +Open a connection in TablePro and bring its window to front. If the connection is already open, the existing window is focused. **Input**: `{ "connection_id": "..." }` @@ -338,7 +340,7 @@ Open a connection in TablePro and bring its window to front. } ``` -**Scope**: `readWrite`. +**Scope**: `readOnly`. ### `open_table_tab` @@ -368,11 +370,11 @@ Open a table tab. } ``` -**Scope**: `readWrite`. +**Scope**: `readOnly`. ### `focus_query_tab` -Bring an existing tab to front. +Bring an existing tab to front. The `tab_id` comes from `list_recent_tabs`. **Input**: `{ "tab_id": "..." }` @@ -387,11 +389,13 @@ Bring an existing tab to front. } ``` -**Scope**: `readWrite`. +If the tab is no longer open, the call returns `-32602 invalid params` with detail `tab not found`. + +**Scope**: `readOnly`. ### `list_recent_tabs` -Read the cross-window tab registry. +Read the cross-window tab registry. Tabs from connections with `externalAccess: blocked` are filtered out. **Input**: `{ "limit": 20 }` (optional, 1-500, default 20). @@ -465,25 +469,37 @@ Full-text search over the query history database. ## Errors -All tools return JSON-RPC errors with these codes: - -| Code | Meaning | -|------|---------| -| `400` | Invalid input | -| `401` | Missing or invalid bearer token | -| `403` | Token scope or `externalAccess` rejects the request | -| `404` | Connection, table, or tab not found | -| `408` | Query timeout | -| `429` | Rate limit | -| `500` | Server error | - -Error responses include a `message` field. Example: +Tool failures come back as JSON-RPC error envelopes. Codes follow the JSON-RPC spec plus TablePro's reserved range: + +| JSON-RPC code | HTTP status | Meaning | +|---------------|-------------|---------| +| `-32700` | 400 | Parse error (malformed JSON body) | +| `-32600` | 400 | Invalid request (bad envelope, missing `Mcp-Session-Id`) | +| `-32601` | 200 / 404 | Method or resource URI not found | +| `-32602` | 200 | Invalid params (bad input, unknown tab id, unknown connection) | +| `-32603` | 500 | Internal error | +| `-32001` | 404 / 401 | Session not found, or unauthenticated | +| `-32002` | 200 | Request cancelled | +| `-32003` | 200 | Request timeout (e.g. query timeout) | +| `-32004` | 404 | Resource not found | +| `-32005` | 413 | Payload too large | +| `-32007` | 403 | Forbidden (token scope, allowlist, or `externalAccess` rejects) | +| `-32008` | 401 | Token expired | +| `-32000` | 429 / 503 | Server error (rate limited, service unavailable) | + +Error responses include a `message`. Example: ```json { + "jsonrpc": "2.0", + "id": 7, "error": { - "code": 403, - "message": "Connection is read-only for external clients" + "code": -32007, + "message": "Forbidden: Connection is read-only for external clients" } } ``` + +A `404` from `GET/POST/DELETE /mcp` with a stale `Mcp-Session-Id` returns the JSON-RPC envelope with `code: -32001, message: "Session not found"`. Per the [MCP spec](https://modelcontextprotocol.io), clients MUST treat that response as a signal to start a new `initialize` handshake before retrying. + +`401` responses include a `WWW-Authenticate: Bearer realm="TablePro MCP"` header. When the token has expired, the challenge adds `error="invalid_token", error_description="token_expired"`. diff --git a/docs/external-api/tokens.mdx b/docs/external-api/tokens.mdx index e9014a5e7..ad6933fa3 100644 --- a/docs/external-api/tokens.mdx +++ b/docs/external-api/tokens.mdx @@ -28,15 +28,25 @@ The `prefix` is shown in the token list so the user can identify a token without ## Scopes -| Scope | Read schema | SELECT | INSERT/UPDATE/DELETE | DROP/TRUNCATE | UI mutation | -|-------|:-----------:|:------:|:--------------------:|:-------------:|:-----------:| -| `readOnly` | yes | yes | no | no | no | -| `readWrite` | yes | yes | yes | no | yes | -| `fullAccess` | yes | yes | yes | yes (with phrase) | yes | +A token's `permissions` value maps to the MCP scopes the server enforces: -UI mutation covers `open_connection_window`, `open_table_tab`, `focus_query_tab`. These open windows and tabs in the running app. +| Token permission | MCP scopes granted | +|------------------|--------------------| +| `readOnly` | `tools:read`, `resources:read` | +| `readWrite` | `tools:read`, `tools:write`, `resources:read` | +| `fullAccess` | `tools:read`, `tools:write`, `resources:read`, `admin` | -DROP and TRUNCATE always require an explicit confirmation phrase via `confirm_destructive_operation`, even with `fullAccess`. There is no token scope that bypasses the phrase. +What each token can do: + +| Permission | Read schema | SELECT | INSERT/UPDATE/DELETE | DROP/TRUNCATE | switch_database/switch_schema | open / focus tabs | +|------------|:-----------:|:------:|:--------------------:|:-------------:|:----------------------------:|:-----------------:| +| `readOnly` | yes | yes | no | no | no | yes | +| `readWrite` | yes | yes | yes | yes (with phrase) | yes | yes | +| `fullAccess` | yes | yes | yes | yes (with phrase) | yes | yes | + +Navigation tools (`open_connection_window`, `open_table_tab`, `focus_query_tab`, `list_recent_tabs`) need only `tools:read`. They surface UI but never bypass the connection allowlist or `externalAccess: blocked`. + +DROP and TRUNCATE always require an explicit confirmation phrase via `confirm_destructive_operation`, plus a token with `tools:write` (i.e. `readWrite` or `fullAccess`). There is no token permission that bypasses the phrase. ## Connection allowlist @@ -56,11 +66,11 @@ The effective permission is `MIN(token.scope, connection.externalAccess)`. | `readOnly` | `readWrite` | `readOnly` | | `readWrite` | `readOnly` | `readOnly` | | `fullAccess` | `readOnly` | `readOnly` | -| `fullAccess` | `readWrite` | `readWrite` (no destructive) | +| `fullAccess` | `readWrite` | `readWrite` | | `fullAccess` | `blocked` | denied | | any | `blocked` | denied | -A `fullAccess` token cannot mutate data on a `readOnly` connection. A token's reach is bounded by both itself and the connection. +A `fullAccess` or `readWrite` token cannot mutate data on a `readOnly` connection. A token's reach is bounded by both itself and the connection's `externalAccess`. ## Creation @@ -110,16 +120,15 @@ Entries are kept for 90 days, auto-pruned on app launch. ## Rate limits -Per-IP, on failed auth: +The MCP authenticator throttles failed token attempts. The bucket key is `(client_address, principal_fingerprint)`, so a misbehaving bridge cannot lock out other principals on the same loopback address. -| Failures | Lockout | -|----------|---------| -| 2 | 1 second | -| 3 | 5 seconds | -| 4 | 30 seconds | -| 5+ | 5 minutes | +| Setting | Value | +|---------|-------| +| Failure window | 60 seconds | +| Max failures in window | 5 | +| Lockout after threshold | 5 minutes | -A successful auth resets the counter. During lockout the server returns `429 Too Many Requests`. +A successful auth clears the bucket. During lockout the server returns HTTP `429 Too Many Requests` with JSON-RPC `code: -32000, message: "Rate limited"`. ## What tokens cannot do diff --git a/docs/external-api/url-scheme.mdx b/docs/external-api/url-scheme.mdx index 4f9b43ff1..4bec5423f 100644 --- a/docs/external-api/url-scheme.mdx +++ b/docs/external-api/url-scheme.mdx @@ -67,25 +67,19 @@ open "tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1/database/app/schem ``` tablepro://connect//query?sql= -tablepro://connect//query?sql=&token= ``` -Opens a new query tab with the SQL pre-filled. Without a `token`, TablePro shows a confirmation dialog with the SQL before opening, so the user can verify the query is safe. +Opens a new query tab with the SQL pre-filled. TablePro always shows a confirmation dialog with a preview of the SQL before opening, so the user can verify the query is safe. The query does not auto-execute; the user runs it from the editor. The SQL has a 51,200-character cap. -If a valid `token` is provided and the token has `query.write` scope (i.e. `readWrite` or `fullAccess`), the confirmation is skipped. The token is matched against the active connection's `externalAccess` level. A read-only connection rejects any write SQL regardless of token scope. +To run SQL from a script and read rows back, use the MCP [`execute_query`](/external-api/mcp-tools) tool instead. The URL scheme is for handing SQL into the GUI, not for headless execution. ```bash -# With confirmation open "tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1/query?sql=SELECT%20*%20FROM%20users%20LIMIT%2010" - -# With token, no confirmation -open "tablepro://connect/9f1f0c3e-2e3d-4b14-9c3a-1d2f4ad1f6f1/query?sql=SELECT%20*%20FROM%20users%20LIMIT%2010&token=tp_abc123..." ``` | Parameter | Required | Description | |-----------|----------|-------------| -| `sql` | yes | Percent-encoded SQL. | -| `token` | no | Bearer token. Skips the confirmation dialog when present and valid. | +| `sql` | yes | Percent-encoded SQL. Max 51,200 characters. | ## Start pairing diff --git a/docs/external-api/versioning.mdx b/docs/external-api/versioning.mdx index 905fab298..6902c0ebb 100644 --- a/docs/external-api/versioning.mdx +++ b/docs/external-api/versioning.mdx @@ -7,6 +7,8 @@ description: Stability policy for the URL scheme, MCP tools, and resource catalo The External API follows TablePro's semver. The contract is the URL scheme, the MCP tool catalog, the resource list, and the pairing flow. +The MCP server reports `protocolVersion: "2025-03-26"` from `initialize`. That value comes from the [Model Context Protocol spec](https://modelcontextprotocol.io) and is independent of TablePro's app version. + ## Stability rules Within a major version, the External API is **additive only**: diff --git a/docs/features/mcp.mdx b/docs/features/mcp.mdx index 16f43f627..8120c91d1 100644 --- a/docs/features/mcp.mdx +++ b/docs/features/mcp.mdx @@ -157,4 +157,4 @@ The reachable surface is the [tool catalog](/external-api/mcp-tools) and the [UR **Certificate trust error**: export the PEM from **Settings > Integrations > Network** and add it to your client's trust store, or use fingerprint pinning. -**`429 Too Many Requests`**: too many failed auth attempts. The lockout escalates to 5 minutes and resets on the next successful auth. +**`429 Too Many Requests`**: 5 failed auth attempts within 60 seconds against the same `(client_address, principal)` pair triggers a 5-minute lockout. A successful auth clears the bucket. diff --git a/docs/refactor/hig-audit/00-overview.md b/docs/refactor/hig-audit/00-overview.md new file mode 100644 index 000000000..4b70fcdbf --- /dev/null +++ b/docs/refactor/hig-audit/00-overview.md @@ -0,0 +1,148 @@ +# TablePro × macOS HIG Audit + +**Status**: Audit complete (2026-05-01). Refactor pending. +**Scope**: Full audit of TablePro against Apple macOS Human Interface Guidelines. +**Goal**: Identify every incorrect/non-native pattern. Refactor in dev stage before public release. + +## Why this audit + +TablePro is a native macOS database client. CLAUDE.md commits to "native only — no cross-platform abstractions". A `Cmd+N` review on 2026-05-01 found: + +- `Cmd+N` labeled "Manage Connections" — violates HIG "Cmd+N = New X" +- `CommandGroup(replacing: .newItem)` removes the default "New Window" entirely +- Behavior is `openOrFront()` (show window), not "create new" + +If one shortcut is wrong, others likely are too. We're in dev stage. Fix it all now, not after release. + +## Headline numbers + +**174 findings** across 4 domains: + +| Report | P0 | P1 | P2 | Total | +|--------|----|----|----|-------| +| [01 — Menus & Shortcuts](01-menus-shortcuts.md) | 13 | 30 | 27 | 70 | +| [02 — Windows & Interactions](02-windows-interactions.md) | 8 | 19 | 11 | 38 | +| [03 — Chrome & Visual](03-chrome-visual.md) | 18 | 17 | 7 | 42 | +| [04 — System & Document](04-system-document.md) | 7 | 11 | 6 | 24 | +| **Total** | **46** | **77** | **51** | **174** | + +P0 = broken native contract (will trip native users immediately). +P1 = non-idiomatic (works, but not how Apple/competitors do it). +P2 = polish (label wording, ellipsis, separator placement). + +## Cross-cutting themes + +Findings cluster around 5 root issues. Fixing each one collapses many leaf items. + +### T1 — No Apple document model + +TablePro hand-rolls SQL-file editing on top of `QueryTab` + manual `NSWindow.representedURL`/`isDocumentEdited` wiring. No `NSDocument`, `FileDocument`, or `DocumentGroup`. This single gap causes: + +- No Open Recent (01-File-menu, 04-system) +- No auto-save / Versions / Time Machine (04-system) +- No Revert / Duplicate / Rename / Move To… in File menu (01-File, 04-system) +- Custom quit-review alert reimplements `NSDocument` (04-system, `AppDelegate.swift:99`) +- "Save Changes" instead of "Save" (01-File) +- Cmd+W close logic that can produce empty windows (02-windows) + +**Fix**: Adopt `FileDocument` for `.sql` files, route through `DocumentGroup`. Apple gives the rest for free. + +### T2 — Keyboard shortcut sweep + +Five system-shortcut conflicts and several semantic mismatches in `KeyboardShortcutModels.swift`: + +- `Cmd+N` → "Manage Connections" (HIG: "New X") +- `Cmd+D` → "Save as Favorite" (HIG: "Duplicate") +- `Cmd+Y` → app action (system: Quick Look) +- `Cmd+Option+Delete` → app action (system: Empty Trash) +- `Cmd+Ctrl+C` → app action (system: Color Picker) +- `Cmd+L` → app action (system: URL bar focus; also collides with `Cmd+Shift+L = Format Query`) + +Plus missing Find submenu (Cmd+G / Cmd+Shift+G / Use Selection / Jump), no Cmd+1…9 tab quick-jump, label inconsistencies ("Toggle X" vs "Show/Hide X"). + +**Fix**: One sweep PR: rebind conflicts, add Find submenu, normalize labels. + +### T3 — Custom chrome where standard exists + +Visual chrome reimplements native components 80+ times: + +- 80 sites of `.font(.system(size:))` — none scale with Dynamic Type +- `WelcomeButtonStyle`, `KeyboardHint` badges, `TagBadgeView` capsules, custom inspector pills, custom popover selection backgrounds +- Welcome window hides traffic lights and standard title bar +- Hard-coded `.yellow` / `.pink` literals instead of `Color(nsColor: .systemYellow)` + +**Fix**: Mechanical removal in favor of `.bordered` / `.borderless` / semantic colors / `ContentUnavailableView` / system List selection. + +### T4 — Modal patterns are wrong + +Sheets stack on sheets in Export, Import, DatabaseSwitcher, ConnectionForm. License activation is called as a sheet from 6 different places. Every sheet containing a `TextEditor` or long form has no `minWidth`/`minHeight`. Destructive alerts use `.defaultAction` Return shortcut. + +**Fix**: License activation → standalone panel (one window, six triggers). Replace nested sheets with inline state or `NavigationStack` push. Add resize bounds to every sheet. Default-Cancel on destructive prompts. + +### T5 — Settings architecture is dated + +`SettingsView.swift` uses pre-Sonoma `TabView` toolbar with 9 tabs. macOS 14 standard (System Settings, Xcode 15, Notes) is `NavigationSplitView` with sidebar + detail. + +**Fix**: Migrate `Settings { TabView { ... } }` → `Settings { NavigationSplitView { ... } }`. + +## Recommended PR sequence + +Order matters: T1 first because it kills 6+ P0s on its own. T2 is mechanical and unblocks a clean menu structure. T3-T5 can run in parallel after. + +| # | PR | Theme | Effort | Kills | Notes | +|---|----|-------|--------|-------|-------| +| 1 | Adopt `FileDocument` + `DocumentGroup` for SQL files | T1 | L (multi-day) | ~6 P0, ~5 P1 | Foundation. Unlocks Open Recent, auto-save, Revert, quit-review for free. | +| 2 | Keyboard shortcut sweep (system conflicts + labels) | T2 | M | 6 P0, 8 P1 | Mostly mechanical. Touches `KeyboardShortcutModels.swift` + menu bindings. | +| 3 | Find submenu + Window menu completeness | T2 | S | 2 P0, 3 P1 | Add Cmd+G / Cmd+Shift+G / Cmd+E. Add "Show All Tabs", "Move Tab to New Window", "Merge All Windows". | +| 4 | Cmd+W close logic fix + tab close edge cases | T1/T2 | M | 1 P0 | Fix inverted single-tab close that produces empty windows. | +| 5 | Drop nested sheets across Export/Import/DB-switcher/ConnectionForm | T4 | L | 3 P0, 4 P1 | Cross-cutting. Each module needs its own refactor. | +| 6 | License activation → standalone `NSPanel` | T4 | M | 1 P0, 1 P1 | Six call sites today. One panel, six triggers. | +| 7 | Welcome window native chrome | T3 | M | 4 P0, 3 P1 | Restore title bar + traffic lights, drop `WelcomeButtonStyle`, drop `KeyboardHint`. | +| 8 | Typography sweep: 80 hard-coded sizes → semantic styles | T3 | M | 1 P0, 4 P1 | Mechanical, do per-folder. | +| 9 | Settings migration: `TabView` → `NavigationSplitView` | T5 | M | 1 P0, 2 P1 | Match macOS 14 System Settings. | +| 10 | Drop custom chrome: capsules, pills, KeyboardHint, TagBadgeView | T3 | M | 4 P0, 3 P1 | After PR 7 lands. | +| 11 | RightSidebar → Inspector (rename + restructure) | T3 | M | 2 P0, 2 P1 | Move mode picker, drop ALL CAPS section titles. | +| 12 | Sidebar filter search field + reduce min-width | T3 | S | 1 P0, 1 P1 | Add search above table list. | +| 13 | Empty states → `ContentUnavailableView` | T3 | M | 1 P0 | One pass across Welcome, Results, History, Editor placeholders. | +| 14 | Accessibility pass (icon-only labels, hints, reduce motion) | T3 | M | 3 P0, 1 P1 | Icon-only buttons need `.accessibilityLabel`. Welcome transition needs `accessibilityReduceMotion`. | +| 15 | Sheet sizing + destructive alert defaults | T4 | S | 2 P1 | Add `min/idealWidth` and `min/maxHeight` to sheets with editors. Set `hasDestructiveAction = true` everywhere. | +| 16 | Backspace vs forward Delete audit | T2 | S | 1 P1 | Replace `.onKeyPress(.delete)` with the `\u{7F}\u{08}` charset. | +| 17 | About panel: ship `Credits.rtf`, drop hand-built links | — | S | 1 P1 | Stop overriding `applicationDidFinishLaunching` for about. | +| 18 | Drag-out from data grid (TSV/HTML to clipboard/drag) | T4 | M | 1 P0 | Drop same-table-only check. | +| 19 | QuickSwitcher → floating panel; add Cmd+1…9 tab quick-jump | T2 | M | 1 P1 | Standalone panel anchored above key window. | +| 20 | Polish/P2 sweep | — | S | 51 P2 | Labels, ellipses, ALL CAPS, separator placement. Last. | + +**Out of scope for this audit (separate decisions)**: +- Mac App Store / sandboxing (`ENABLE_APP_SANDBOX = NO`) — flagged in 04-system but is a parallel-project decision, not a refactor. +- Mac Catalyst / iOS — covered in iOS roadmap. +- `UNUserNotificationCenter` for query/sync events — flagged in 04-system, not blocking. +- Quick Look extension for `.sql` — nice-to-have, defer. + +## Top 10 P0s by user-visible impact + +If we shipped today, these are what an Apple-fluent user would notice in the first 30 minutes: + +1. **Cmd+N opens "Manage Connections"** instead of creating something — `KeyboardShortcutModels.swift:460`, `TableProApp.swift:198` +2. **No Open Recent menu** — completely missing — `TableProApp.swift:204` +3. **No auto-save / no Revert / no Duplicate / no Rename / no Move To** — File menu is incomplete — `TableProApp.swift:204-256` +4. **Cmd+W can produce an empty window** — close logic inverted — `TabWindowController.swift` +5. **Cmd+D bound to "Save as Favorite", not "Duplicate"** — `KeyboardShortcutModels.swift` +6. **Cmd+Y / Cmd+Option+Delete / Cmd+Ctrl+C / Cmd+L collide with system shortcuts** — `KeyboardShortcutModels.swift` +7. **Welcome window has no traffic lights or standard title bar** — `WelcomeWindowFactory` +8. **80 hard-coded font sizes** — nothing scales with Dynamic Type — across `Views/` +9. **Sheets stack on sheets** — Export, Import, DatabaseSwitcher, ConnectionForm — multiple files in `Views/` +10. **Icon-only buttons missing `.accessibilityLabel`** — VoiceOver reads them as "Button" — across `Views/` + +## How to use this document + +- **Each PR**: pick one row from the sequence table. The detail file (`01`-`04`) has the exact `file:line` and fix. +- **Mark progress**: tick off rows in the sequence table as PRs land. +- **Don't bulk-merge**: each PR is bounded so review stays human-sized. +- **Don't drop P2s**: they're polish, but they accumulate. Schedule PR 20 for end-of-stream. + +## Audit metadata + +- Run on 2026-05-01 with 4 specialist agents in parallel: `menu-shortcut-auditor`, `window-interaction-auditor`, `chrome-visual-auditor`, `system-document-auditor`. +- Audit team: `~/.claude/teams/hig-audit/`. Task list: `~/.claude/tasks/hig-audit/`. +- Source pinned at branch `feat/raycast-integration`, commit `2f5b4f8e`. +- Auditors were read-only; no source files were modified during the audit. diff --git a/docs/refactor/hig-audit/01-menus-shortcuts.md b/docs/refactor/hig-audit/01-menus-shortcuts.md new file mode 100644 index 000000000..a52cddd21 --- /dev/null +++ b/docs/refactor/hig-audit/01-menus-shortcuts.md @@ -0,0 +1,648 @@ +# HIG Audit: Menus & Keyboard Shortcuts + +Audit of `TablePro/` against Apple's macOS Human Interface Guidelines for menu +structure, menu wording, command placement, and keyboard shortcut semantics. + +Source of truth audited: + +- `TablePro/Models/UI/KeyboardShortcutModels.swift` (`KeyboardSettings.defaultShortcuts`) +- `TablePro/TableProApp.swift` (`AppMenuCommands`, `PasteboardCommands`) +- Context menus in `TablePro/Views/**` +- AppKit menus in `TablePro/Views/Results/TableRowViewWithMenu.swift`, + `TablePro/Views/Editor/AIEditorContextMenu.swift`, + `TablePro/Views/Terminal/TerminalTabContentView.swift` + +The pre-existing finding for `Cmd+N` -> "Manage Connections" is intentionally +excluded; everything else below is new. + +--- + +## P0 — Broken native contracts + +### [P0] `Cmd+Option+Delete` is the system shortcut for "Empty Trash" + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:493` +- **Current**: `.truncateTable: KeyCombo(key: "delete", option: true, isSpecialKey: true)` (no Cmd modifier; raw `Option+Delete`). +- **HIG says**: `Option+Delete` (without Command) deletes the previous word in any text field. `Shift+Cmd+Delete` is "Empty Trash" in Finder. Bare modified-Delete combinations on table data are dangerous because the same chord may be interpreted as a destructive Finder action when focus is ambiguous, and `Option+Delete` already has a meaning in any text field that takes focus inside the data grid. +- **Native examples**: Finder "Empty Trash" `Shift+Cmd+Delete`; `Option+Delete` deletes-word in TextEdit, Mail, Notes, Safari URL bar. +- **Fix**: Drop a default shortcut entirely for `truncateTable`. Truncating an entire table is a rare, destructive, multi-step operation; it should require an explicit menu pick or context menu and a confirmation sheet. If a default is desired, scope it to data-grid focus only and pick a non-text-mutating chord (e.g. `Cmd+Backspace` only when the sidebar/data grid is first responder). +- **Effort**: S + +### [P0] `Cmd+L` collides with the system "address bar" semantic and Apple's text-list shortcut + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:512` +- **Current**: `.aiExplainQuery: KeyCombo(key: "l", command: true)`. Bare `Cmd+L` triggers an AI feature that isn't even visible in the toolbar by default. +- **HIG says**: `Cmd+L` is the macOS "Open Location" / address-bar / link-to convention (Safari, Chrome, Mail "Add Link", Messages "Add Link", any Finder window with "Go to Folder" via `Cmd+Shift+G`). Allocating `Cmd+L` to an AI explanation is surprising and steals a system-typical chord. +- **Native examples**: Safari, Chrome, Firefox -> focus URL bar. Notes / Mail -> add link. Pages -> "Show/Hide List Format Inspector". +- **Fix**: Move AI to the standard "Writing Tools" / "Smart" cluster: `Cmd+Ctrl+E` or `Cmd+Shift+A` (DataGrip uses `Cmd+Shift+A` for "Find Action"; Xcode reserves `Cmd+Shift+A` for "Action") - or, since AI features already live behind a settings toggle, ship without a default shortcut and let users assign one in Settings -> Keyboard. +- **Effort**: S + +### [P0] `Cmd+D` is mapped to "Save as Favorite", inverting the macOS "Duplicate" convention + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:495` (`.saveAsFavorite`), `TablePro/Models/UI/KeyboardShortcutModels.swift:492` (`.duplicateRow`). +- **Current**: `Cmd+D` -> Save as Favorite. `Cmd+Shift+D` -> Duplicate Row. +- **HIG says**: `Cmd+D` is the standard "Duplicate" chord across Finder, Pages, Numbers, Keynote, Photos, and the AppKit responder chain (`duplicate:`). `Cmd+Shift+D` has no fixed Apple meaning, but Mail uses it for "Send Again" and Safari for "Add Bookmark to Favorites". Putting Duplicate behind Shift inverts user muscle memory and competes with macOS's own bookmarking chord on `Cmd+D`. +- **Native examples**: Finder "Duplicate" `Cmd+D`; Pages/Keynote/Numbers "Duplicate Selection" `Cmd+D`; Photos "Duplicate" `Cmd+D`. +- **Fix**: Bind `Cmd+D` to `.duplicateRow`. Move `.saveAsFavorite` to `Cmd+Shift+D` (matches Safari "Add to Favorites") or to a non-conflicting modifier such as `Cmd+Option+S`. +- **Effort**: S + +### [P0] `Cmd+Y` is reserved by macOS for Quick Look + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:501` +- **Current**: `.toggleHistory: KeyCombo(key: "y", command: true)` (bare `Cmd+Y`). +- **HIG says**: `Cmd+Y` is the system Quick Look shortcut (Finder, Mail, Messages). Apple also uses `Cmd+Y` for "Show History" in Safari, but that is a window-opening action, not a sidebar toggle. Either way, the bare `Cmd+Y` chord is owned by Finder Quick Look, and shadowing it with a panel toggle is non-idiomatic. +- **Native examples**: Finder/Mail/Messages -> Quick Look. Safari -> Show History (full window, not a side panel). +- **Fix**: Move history-panel toggle to a chord consistent with the other side-panel toggles in the app: e.g. `Cmd+Option+H` (mirrors `Cmd+Option+I` for Inspector) or `Cmd+Shift+H` (note: macOS reserves `Cmd+Shift+H` for "Go Home" in Finder, so prefer `Cmd+Option+H`). +- **Effort**: S + +### [P0] `Cmd+Shift+E` overlaps with Mail's "Send Later" / Notes' "Show All Tags" but more importantly inverts the menu-vs-shortcut hierarchy + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:473` (`.export`), `TablePro/Models/UI/KeyboardShortcutModels.swift:471` (`.explainQuery`). +- **Current**: `.export` = `Cmd+Shift+E`, `.explainQuery` = `Cmd+Option+E`. Both `Cmd+Shift+E` and `Cmd+Option+E` are non-standard chords and both share the `e` key, leading to conflicts when users are scanning a Query menu. +- **HIG says**: Apple does not reserve `Cmd+E` for Export; its meaning across apps is "Use Selection for Find" (`findFromSelection:`). Shift/Option variants of `Cmd+E` do not have Apple-mandated meanings, but stacking two near-identical `e` chords on different actions in adjacent menus violates "Avoid Modifier Combinations That Are Hard to Remember" (HIG: Keyboard). +- **Native examples**: Numbers "Export to..." has no default keyboard shortcut. Pages "Export to..." has no default keyboard shortcut. Xcode "Export..." has no default keyboard shortcut. Apps that bind Export typically use `Cmd+Shift+E` (VS Code) but never alongside another `e` chord. +- **Fix**: Drop the default shortcut for `.explainQuery` (it lives behind a menu item and is rarely used by keyboard). Keep `Cmd+Shift+E` for Export only. +- **Effort**: S + +### [P0] `Cmd+R` for "Refresh" is in the Query menu, not the View menu + +- **File**: `TablePro/TableProApp.swift:344-348` (Refresh button is inside `CommandMenu("Query")`). +- **Current**: Refresh sits in the Query menu next to Execute / Format / Cancel. +- **HIG says**: `Cmd+R` is universally a refresh/reload action and the menu placement convention is the View menu (Safari "Reload Page", Mail "Get New Mail", Finder "Refresh") or the Window menu (older apps). Putting it in Query implies the action only refreshes the query, when it actually fires the global `.refreshData` notification (sidebar + coordinator + structure view) and reloads the selected table. +- **Native examples**: Safari View > Reload Page `Cmd+R`. Mail Mailbox > Get New Mail `Cmd+Shift+N` (but `Cmd+R` reloads). Xcode View > Reload `Cmd+Shift+H`. +- **Fix**: Move "Refresh" into the View menu. Optionally add a separate "Re-run Query" item in the Query menu if desired (but `.executeQuery` already covers that). +- **Effort**: S + +### [P0] "Switch Connection..." and "Quick Switcher..." are in the Query menu + +- **File**: `TablePro/TableProApp.swift:386-390` (Switch Connection in Query), `TablePro/TableProApp.swift:350-354` (Quick Switcher in Query). +- **Current**: Both connection-level navigation actions are in the Query command menu. +- **HIG says**: The Query menu's purpose is operations on the current query/result. Switching the active connection or jumping to another table is a File-menu or Window-menu concern. Apple HIG: "Group menu items by the kind of action they perform." +- **Native examples**: TablePlus "Switch Connection..." in File menu. Sequel Ace "Choose Connection..." in File menu. DataGrip "Open Recent" in File menu. +- **Fix**: Move "Switch Connection..." to File. Move "Quick Switcher..." to File (or keep as Window-menu "Show Tab Bar" style action). Cmd+K and Cmd+Shift+O semantics also drift toward "open something" rather than "do something with this query". +- **Effort**: S + +### [P0] "Open Database..." (`Cmd+K`) collides with Finder's "Connect to Server..." + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:463` (`.openDatabase`). +- **Current**: `Cmd+K` opens the database switcher (the panel that lets you pick which database/schema this connection should target). +- **HIG says**: `Cmd+K` in macOS is "Connect to Server" in Finder, "Add Link" in mail/notes, "Clear Screen" in Terminal. It is not a "switch context within the current connection" chord. Furthermore, "Open Database" reads like an action that opens a `.sqlite` file - which would conflict with `.openFile` (`Cmd+O`). +- **Native examples**: Xcode `Cmd+Shift+O` "Open Quickly". TablePlus "Switch Database" `Cmd+K` (TablePlus has the same problem). DataGrip "Switch Database" no default chord. +- **Fix**: Rename the menu item to "Switch Database..." (it is not opening anything). Move the chord to `Cmd+Shift+K` or unbind by default. `Cmd+K` is also the de-facto chord for the AI "Command Palette" pattern in modern editors (Cursor, Continue) - consider that too. +- **Effort**: S + +### [P0] "Open File..." labeled and shortcut-mapped as a generic file open, but it is SQL-only + +- **File**: `TablePro/TableProApp.swift:222-226`, `TablePro/Models/UI/KeyboardShortcutModels.swift:464`. +- **Current**: Menu item "Open File..." with `Cmd+O` calls `actions?.openSQLFile()`. +- **HIG says**: `Cmd+O` is the standard Open File chord and users expect a generic `NSOpenPanel` that accepts "everything this app can read". TablePro can also open `.sqlite`/`.duckdb` database files (per the registered UTIs), and connection import files (`.tablepro`). The single "Open File..." entry only handles SQL. This violates HIG "Make a menu item's behavior match its name". +- **Native examples**: TextEdit, Pages, Xcode -> Open File dialog supports all known doc types. Finder Open With -> uses UTIs. +- **Fix**: Either rename to "Open SQL File..." (clearer scope) or expand the Open dialog to accept SQL + import + standalone database files and route appropriately in the openHandler. +- **Effort**: M + +### [P0] "Toggle Sidebar" / "Toggle Inspector" / "Toggle Filters" / "Toggle History" / "Toggle Results" use static "Toggle" labels + +- **File**: `TablePro/TableProApp.swift:461,466,474,480,488` (View menu). +- **Current**: All five menu items are statically labeled "Toggle X". The label never changes when the panel is open vs closed. +- **HIG says**: "Use accurate, descriptive titles for menu items. ... When a menu item toggles between two states, change the title to reflect the action it will perform." (Apple HIG: Menus -> Use Toggle Items Sparingly.) Apple's own apps use "Show Sidebar" / "Hide Sidebar". +- **Native examples**: Finder "Show Sidebar" / "Hide Sidebar" (`Cmd+Ctrl+S`). Mail "Show Mailbox List" / "Hide Mailbox List". Xcode "Show Navigator" / "Hide Navigator". Notes "Show Folders" / "Hide Folders". +- **Fix**: Read the panel state at menu build time and switch labels: `splitViewController.isSidebarCollapsed ? "Show Sidebar" : "Hide Sidebar"`, etc. Same for inspector/filters/history/results. +- **Effort**: M (state has to be observable from the menu builder; `@FocusedValue` already provides the actions object - extend it with `isFilterPanelVisible`, `isInspectorVisible`, ...). + +### [P0] `Cmd+Ctrl+C` for "Switch Connection" is reserved by macOS for the Color Picker + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:465` +- **Current**: `.switchConnection: KeyCombo(key: "c", command: true, control: true)`. +- **HIG says**: `Cmd+Ctrl+C` is the system-wide Color Picker shortcut on macOS (NSColorPanel's pickup tool); it also clashes with VoiceOver `Cmd+Ctrl+C` "Read All". Reusing it is an accessibility regression. +- **Native examples**: Apple Color Picker, VoiceOver. +- **Fix**: Drop the default. Leave switching to the menu item (the user can rebind in Settings -> Keyboard if they want a chord). If a default is needed, prefer `Cmd+Shift+C` (note Mail uses `Cmd+Shift+C` for "Reply with iMessage" but TablePro is not in Mail's contention space). +- **Effort**: S + +### [P0] `Cmd+Ctrl+`` for "Open Terminal" is non-standard and ambiguous with Cmd+`` (window cycling) + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:476`. +- **Current**: `.openTerminal: KeyCombo(key: "`", command: true, control: true)`. +- **HIG says**: `Cmd+`` is the macOS window-cycling chord for the same app (already in `KeyCombo.systemReserved`). Adding `Ctrl` produces a chord that is genuinely free, but the convention for "Show Terminal" in IDEs is `Cmd+Option+T` (DataGrip), `Cmd+`` (VS Code, conflicts with system), or `Ctrl+`` (Xcode does not have a built-in terminal). +- **Native examples**: VS Code `Ctrl+`` (without Cmd) toggles terminal. JetBrains `Cmd+Option+0` shows the Terminal tool window. Xcode opens external Terminal. +- **Fix**: Either drop the default chord, or move to `Cmd+Option+T` (matches DataGrip) which has no built-in macOS conflict. Keep the menu item under "View" only if the terminal is a panel; if it opens a separate window, move it to File ("Open Terminal Window"). +- **Effort**: S + +### [P0] No "New Window" (`Cmd+N`) anywhere in the menu + +- **File**: `TablePro/TableProApp.swift:197-202` (already-known finding for the wrong `Cmd+N` mapping). +- **Current**: `CommandGroup(replacing: .newItem)` removes SwiftUI's default New Window entry entirely. There is no replacement; users have no way to spawn a fresh main window. +- **HIG says**: HIG Window Menu / File Menu both call out "New Window" as a standard, app-level action. Document-based apps must support `Cmd+N`. TablePro is not formally document-based, but the connection-tabbed window is its document analogue. +- **Native examples**: Safari File > New Window `Cmd+N`. Mail File > New Viewer Window `Cmd+Option+N`. Xcode File > New > Window `Cmd+Ctrl+N`. +- **Fix**: Add an explicit "New Main Window" entry (e.g. `Cmd+Ctrl+N`) that opens a new `TabWindowController` for the most-recently-active connection. Or repurpose the to-be-renamed `Cmd+N` ("New Connection") and add `Cmd+Shift+N` for "New Window". +- **Effort**: M + +--- + +## P1 — Non-idiomatic placement, modifier conventions, label problems + +### [P1] "GitHub Repository" wording is inconsistent with the rest of the Help menu + +- **File**: `TablePro/TableProApp.swift:594`. +- **Current**: Help menu has "TablePro Website", "Documentation", and then "GitHub Repository". +- **HIG says**: Help-menu entries should describe the user-visible result, not the technical artifact. "Repository" is a Git concept; users expect a verb-noun or noun phrase like "TablePro on GitHub" / "Source Code on GitHub". +- **Native examples**: Notion Help menu "Visit Notion", Linear "Linear on GitHub". Native apps rarely link to GitHub but follow noun-phrase patterns (Mail "About Mail Filtering"). +- **Fix**: Rename to "TablePro on GitHub" or "Source Code on GitHub". +- **Effort**: S + +### [P1] Help menu omits the standard "TablePro Help" item + +- **File**: `TablePro/TableProApp.swift:583-603` (`CommandGroup(replacing: .help)`). +- **Current**: Replaces the entire Help menu with website / documentation / GitHub / report-issue. +- **HIG says**: Apple HIG (Help menu): "Provide a Help menu and a Help command, and use the standard Help title (App Name Help)." The standard menu item should be "TablePro Help" pointing at help content (in this case: docs.tablepro.app). The Help search field is preserved automatically. +- **Native examples**: Mail "Mail Help", Safari "Safari Help", Notes "Notes Help". All point to Apple-hosted help books or web docs. +- **Fix**: Keep the existing items but rename "Documentation" to "TablePro Help" and place it first, as the canonical Help entry. The search field will continue to work. +- **Effort**: S + +### [P1] "Save As..." chord (`Cmd+Shift+S`) without the Apple-recommended hidden default of "Duplicate" + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:467`, `TablePro/TableProApp.swift:242-246`. +- **Current**: `Cmd+Shift+S` invokes "Save As...". +- **HIG says**: Since macOS 10.7 Apple recommends `Cmd+Shift+S` = "Duplicate" by default and reveals "Save As..." only when the user holds `Option` (Pages, Numbers, Keynote, TextEdit). Document-based apps follow this. TablePro's `.saveFileAs()` is closer to a Pages "Save As" so this is borderline-acceptable, but worth noting. +- **Native examples**: Pages, Numbers, Keynote, TextEdit, Preview - all show "Duplicate" by default and toggle to "Save As..." on Option. +- **Fix**: Hold for now (TablePro is not document-based and "Duplicate" doesn't map to anything sensible). If TablePro ever adds a true SQL-document mode, revisit. +- **Effort**: M (defer) + +### [P1] "Refresh" sits in the Query menu but uses the View menu chord + +- **File**: `TablePro/TableProApp.swift:344-348`. +- **Current**: Refresh in Query menu, `Cmd+R`. +- **HIG says**: See P0 above for placement; chord is correct. Same fix moves both. +- **Effort**: S (combined with the P0 above) + +### [P1] "Cancel Query" (Cmd+.) is correct but lacks ellipsis-or-not consistency + +- **File**: `TablePro/TableProApp.swift:338-342`. +- **Current**: "Cancel Query" - no ellipsis (correct, it acts immediately). +- **HIG says**: Cancel actions never take ellipsis. Already correct. +- **Note**: Just confirming. No change. + +### [P1] Top-level "Query" menu name overlaps with another database client convention + +- **File**: `TablePro/TableProApp.swift:296` (`CommandMenu("Query")`). +- **Current**: Top-level menu is named "Query", containing Execute / Explain / Format / Refresh / Quick Switcher / Switch Connection / Save as Favorite / AI / Preview FK. +- **HIG says**: Custom menus are allowed but should be tightly scoped. Today the Query menu is a grab-bag of unrelated actions (FK preview, AI, connection switching, refresh). HIG: "Limit the scope of each menu so that it contains related items only." +- **Native examples**: TablePlus uses two menus: "Connection" and "Query". DataGrip uses "Database" and "Code". +- **Fix**: Split into two menus: "Database" (Switch Connection, Switch Database, Refresh, Quick Switcher, Server Dashboard) and "Query" (Execute, Execute All, Explain, Format, Cancel, Preview SQL, AI, Preview FK). +- **Effort**: M + +### [P1] AI commands placed at the bottom of "Query" with no visual section header + +- **File**: `TablePro/TableProApp.swift:366-376`. +- **Current**: `Divider()` + two AI buttons inside Query. +- **HIG says**: When a feature cluster (AI) is conditionally available (settings flag), Apple typically places it under its own submenu or hides it entirely when off. Right now AI menu items remain enabled but call into a feature that can be off, leaking surface area. +- **Native examples**: Apple Intelligence "Writing Tools" submenu in Mail, Notes (single "Writing Tools..." entry). +- **Fix**: Group AI under a `Menu("AI")` submenu in Query. Also disable when `AppSettingsManager.shared.ai.enabled == false`. +- **Effort**: S + +### [P1] Edit menu lacks a Find submenu structure + +- **File**: `TablePro/TableProApp.swift:430-433`. +- **Current**: Single "Find..." item, `Cmd+F`. No "Find Next" / "Find Previous" / "Use Selection for Find" / "Replace...". +- **HIG says**: Apple's standard Find submenu in TextEdit / Mail / Pages contains: Find..., Find Next, Find Previous, Use Selection for Find, Jump to Selection. The Edit menu lays them out as a `Menu("Find")` submenu or in the dedicated Find category. +- **Native examples**: TextEdit Edit > Find submenu. Mail Edit > Find submenu. Xcode Find menu (separate top-level). +- **Fix**: Add Edit > Find submenu with Find / Find Next (`Cmd+G`) / Find Previous (`Cmd+Shift+G`) / Use Selection for Find (`Cmd+E`) / Jump to Selection (`Cmd+J`). Most are already supported by the underlying CodeEditTextView - only need menu items routing through `findFromSelection:` etc. +- **Effort**: M + +### [P1] Edit menu lacks Spelling / Substitutions submenus + +- **File**: `TablePro/TableProApp.swift` (Edit menu). +- **Current**: No Spelling and Grammar submenu. SwiftUI's `CommandGroup(replacing: .pasteboard)` removes the entire pasteboard cluster and rebuilds it; the Spelling submenu lives outside that group and would be auto-included, but TablePro overrides too aggressively (see next finding). Verify behavior. +- **HIG says**: The Edit menu standard order is: Undo/Redo, Cut/Copy/Paste/Delete/Select All, Find, Spelling and Grammar, Substitutions, Speech, AutoFill. Most are auto-injected by SwiftUI when you don't replace the relevant CommandGroups. +- **Native examples**: TextEdit, Mail, Notes - all show Spelling, Substitutions, Speech. +- **Fix**: Verify which default CommandGroups are still applied. If Spelling is missing in the SQL editor (where it makes sense for comments), add it. +- **Effort**: S + +### [P1] "Increase Text Size" / "Decrease Text Size" wording + +- **File**: `TablePro/TableProApp.swift:532-540`. +- **Current**: "Increase Text Size" `Cmd+=` / "Decrease Text Size" `Cmd+-`. +- **HIG says**: Apple's standard wording is "Make Text Bigger" / "Make Text Smaller" (Mail, Safari, Messages, Notes). "Increase/Decrease" is engineering-speak. +- **Native examples**: Safari View > Zoom In `Cmd++` / Zoom Out `Cmd+-`. Mail Format > Style > Bigger `Cmd++`. Notes "Make Text Bigger" `Cmd++`. +- **Fix**: Rename to "Bigger" / "Smaller" or "Zoom In" / "Zoom Out". Also add an "Actual Size" `Cmd+0` ... but `Cmd+0` is taken by `.toggleTableBrowser` (see P2). +- **Effort**: S + +### [P1] `Cmd+0` (`.toggleTableBrowser`) overlaps with the universal "Actual Size" convention + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:498`. +- **Current**: `Cmd+0` toggles the sidebar. +- **HIG says**: `Cmd+0` is "Actual Size" in Safari, Preview, Photos. Xcode does use `Cmd+0` for "Show Navigator", which gives TablePro precedent, but mixing the two conventions is confusing in an app that also has zoom shortcuts. +- **Native examples**: Xcode `Cmd+0` Show Navigator (matches TablePro). Safari/Preview/Photos `Cmd+0` Actual Size. +- **Fix**: Match Apple's HIG default for sidebars: `Cmd+Ctrl+S` (Finder, Mail). Free up `Cmd+0` for a future "Actual Size" / "Reset Zoom" if the editor zoom is added. +- **Effort**: S + +### [P1] `Cmd+Shift+F` (`.toggleFilters`) collides with IDE "Find in Project" + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:500`. +- **Current**: `Cmd+Shift+F` toggles the filter panel. +- **HIG says**: `Cmd+Shift+F` is "Find in Project / All Files" in every IDE (Xcode, VS Code, JetBrains, Sublime). TablePro does not have a global find, but using this chord for a panel toggle wastes the slot. +- **Native examples**: Xcode "Find in Project" `Cmd+Shift+F`. VS Code "Find in Files" `Cmd+Shift+F`. +- **Fix**: Move filter-panel toggle to `Cmd+Option+F` (matches the other Option-modified panel toggles like `Cmd+Option+I` for Inspector). +- **Effort**: S + +### [P1] "New Tab" (`Cmd+T`) opens a query tab but does not clarify that + +- **File**: `TablePro/TableProApp.swift:205-208`, `TablePro/Models/UI/KeyboardShortcutModels.swift:462`. +- **Current**: "New Tab" `Cmd+T` calls `actions?.newTab()`. +- **HIG says**: HIG: "Be clear about what tabs are." `Cmd+T` is universally "new tab in this window" (Safari, Terminal, Finder, Xcode). TablePro's tab is a query-editor tab. Label should be "New Query Tab" if a future "New Connection Tab" is conceivable. +- **Native examples**: Safari "New Tab" - one tab type. Xcode "New Tab" - one tab type. Terminal "New Tab" - one tab type. +- **Fix**: Hold. Acceptable as-is. + +### [P1] "Save Changes" wording + +- **File**: `TablePro/TableProApp.swift:230-233`. +- **Current**: Menu item "Save Changes" `Cmd+S`. +- **HIG says**: Apple's File menu standard label is "Save" - never "Save Changes". The "Changes" suffix is implied by the verb. HIG: "Use short, simple verbs." +- **Native examples**: TextEdit, Pages, Xcode - all say "Save". +- **Fix**: Rename menu item to "Save". (The action's `displayName` in `KeyboardShortcutModels.swift:128` can stay "Save Changes" if the settings UI explicitly differentiates from Save File - but the menu label should match Apple's.) +- **Effort**: S + +### [P1] "Manage Connections" should follow `New X` ellipsis convention + +- **File**: `TablePro/TableProApp.swift:198`. +- **Current**: "Manage Connections" with no ellipsis. Already known the chord is wrong; separately the wording lacks an ellipsis even though the action opens a separate window (the Welcome window). +- **HIG says**: HIG: "Append an ellipsis to the title of any menu item that requires further input from the person before the action takes place." Opening a separate window for management is a borderline case - some Apple apps use ellipsis, some don't. The rule of thumb: if the user has to do anything in the new window before something happens, add ellipsis. +- **Native examples**: System Settings > Network "Manage Locations..." (ellipsis). Mail "Manage Mailboxes..." (ellipsis). +- **Fix**: When this is renamed to "New Connection..." per the existing finding, the ellipsis is correct. +- **Effort**: S (folded into existing finding) + +### [P1] "Quick Switcher..." in `KeyboardShortcutModels` displayName is fine, but the menu label should specify what is being switched + +- **File**: `TablePro/TableProApp.swift:350`. +- **Current**: "Quick Switcher..." `Cmd+Shift+O`. +- **HIG says**: The label is generic. Users don't know if it switches connections, tables, queries, or all of the above. HIG: "Use accurate, descriptive titles." +- **Native examples**: Xcode "Open Quickly..." `Cmd+Shift+O`. VS Code "Go to File..." `Cmd+P`. +- **Fix**: Rename to "Open Quickly..." (matches Apple/Xcode) or "Go to..." with a specific scope. +- **Effort**: S + +### [P1] "Preview SQL" label is dynamic with `String(format:)` but uses placeholder for unconnected state + +- **File**: `TablePro/TableProApp.swift:321-329`. +- **Current**: Shows "Preview SQL" when no connection, otherwise "Preview \(language)" (e.g. "Preview MongoDB"). +- **HIG says**: Menu items should not change between connected and disconnected states except to enable/disable. The label flicker between "Preview SQL" and "Preview MongoDB" violates HIG: "Maintain stable menu item titles where possible". +- **Native examples**: Xcode menus do not change titles based on document type. +- **Fix**: Always show "Preview Statement..." or "Preview Pending Changes..." (the latter is more honest - this previews INSERT/UPDATE/DELETE for pending row edits). The current label even misleads users into thinking it previews the editor query. +- **Effort**: S + +### [P1] AI menu items in editor right-click only show when text is selected, hiding the feature + +- **File**: `TablePro/Views/Editor/AIEditorContextMenu.swift:75-96`. +- **Current**: `guard AppSettingsManager.shared.ai.enabled, hasSelection?() == true else { return }` - AI items disappear entirely when no selection. +- **HIG says**: HIG: "Prefer disabling a menu item to removing it." Menu items that vanish based on selection are jarring; users learn the menu's geometry by repetition. +- **Native examples**: Notes "Writing Tools" submenu always visible, individual items disable when nothing is selected. +- **Fix**: Always show the AI items, disable them when `hasSelection?() != true`. +- **Effort**: S + +### [P1] Right-click on data grid row does not show a "Show in Sidebar" / "Reveal" type entry, but does show "Open " inconsistently + +- **File**: `TablePro/Views/Results/TableRowViewWithMenu.swift:119-126` (FK navigation). +- **Current**: FK preview/navigation only appears when the column is a foreign key AND the cell has a value. Reasonable, but the sub-section appears mid-menu without a label. +- **HIG says**: Conditional sub-sections in context menus should be labeled (use a non-clickable header item or leading divider with a `Menu` submenu) when they appear/disappear based on context. HIG: "Group related items." +- **Fix**: Wrap FK actions in a `Menu("Foreign Key")` submenu, or move them under a labelled section. Otherwise the row context menu shifts visually each time. +- **Effort**: S + +### [P1] Result tab right-click menu uses non-standard wording for its "Pin/Unpin" toggle but proper Show/Hide-style toggling + +- **File**: `TablePro/Views/Results/ResultTabBar.swift:62-72`. +- **Current**: `Button(rs.isPinned ? String(localized: "Unpin") : String(localized: "Pin Result"))`. +- **HIG says**: Toggle wording should match: either "Pin Result" / "Unpin Result" (matched verb-noun pair) or "Pin" / "Unpin" (matched single verb). Current pair is mismatched. +- **Native examples**: Safari pinned tabs: "Pin Tab" / "Unpin Tab". +- **Fix**: "Pin Result" / "Unpin Result". +- **Effort**: S + +### [P1] Sidebar context menu "Create New Table..." and "Create New View..." but File menu uses "New View..." (no Create prefix) + +- **File**: `TablePro/Views/Sidebar/SidebarContextMenu.swift:59,64` vs `TablePro/TableProApp.swift:211`. +- **Current**: Sidebar context menu prefixes with "Create"; File menu omits it. +- **HIG says**: HIG: "Use consistent terminology." If the action is the same, the label should be the same. +- **Native examples**: Finder "New Folder" (no "Create"). Pages "New Document" (no "Create"). +- **Fix**: Standardize on "New Table..." / "New View..." (drop "Create"). +- **Effort**: S + +### [P1] Welcome window context menu "Edit" lacks the noun ("Edit Connection") + +- **File**: `TablePro/Views/Connection/WelcomeContextMenus.swift:97`. +- **Current**: `Label(String(localized: "Edit"), systemImage: "pencil")`. +- **HIG says**: Bare verbs in context menus are ambiguous when a row is selected. Should be "Edit Connection" so the action is unambiguous when read out by VoiceOver, or screenshotted, or skim-read. +- **Native examples**: Mail context menu "Edit Account..." not "Edit". Calendar "Edit Event" not "Edit". +- **Fix**: "Edit Connection" / "Duplicate Connection" / "Delete Connection". +- **Effort**: S + +### [P1] Welcome window deletion path uses "Delete" without ellipsis but opens a confirm dialog + +- **File**: `TablePro/Views/Connection/WelcomeContextMenus.swift:74-81,183-188`. +- **Current**: "Delete" / "Delete %d Connections" - no ellipsis - even though `vm.showDeleteConfirmation = true` opens a confirmation sheet. +- **HIG says**: Apple's HIG (Menus): the ellipsis indicates the user must take additional steps before the action completes. A destructive confirm dialog counts. +- **Native examples**: Finder "Move to Trash" (no ellipsis - the action is the one-step move). But "Delete Immediately..." has ellipsis because it requires confirmation. +- **Fix**: Either remove the confirmation dialog (menus already provide enough friction for simple deletes via the destructive role styling) or add ellipsis: "Delete...". +- **Effort**: S + +### [P1] "Bring All to Front" duplicated between SwiftUI default and custom Window menu group + +- **File**: `TablePro/TableProApp.swift:575-577`. +- **Current**: Adds a "Bring All to Front" button under `CommandGroup(after: .windowArrangement)`. SwiftUI's default Window menu already includes this. +- **HIG says**: Standard menu items must not appear twice. +- **Native examples**: Every Apple app has exactly one "Bring All to Front". +- **Fix**: Remove the manually-added "Bring All to Front" button. +- **Effort**: S + +### [P1] "Cancel Query" in Query menu uses `Cmd+.` but sits below higher-frequency items + +- **File**: `TablePro/TableProApp.swift:336-342`. +- **Current**: Cancel Query is below Format Query / Preview SQL. +- **HIG says**: HIG: "Order menu items by frequency or importance." Cancel is a critical, high-importance action - should sit just below Execute / Execute All. +- **Fix**: Reorder the Query menu so Execute / Execute All / Cancel cluster at the top. +- **Effort**: S + +### [P1] Hardcoded Find shortcut `Cmd+F` is not customizable through `KeyboardSettings` + +- **File**: `TablePro/TableProApp.swift:431-433`. +- **Current**: `.keyboardShortcut("f", modifiers: .command)` is hardcoded; `KeyboardSettings.defaultShortcuts` has no `.find` action. +- **HIG says**: Not strictly a HIG violation, but inconsistent with the rest of the menu (every other shortcut routes through `optionalKeyboardShortcut(shortcut(for:))` so users can rebind). +- **Fix**: Add `.find` to `ShortcutAction` and `defaultShortcuts`, route through the same path. +- **Effort**: S + +### [P1] Hardcoded Execute / Execute All Statements / Cancel / Bigger / Smaller shortcuts are not customizable + +- **File**: `TablePro/TableProApp.swift:300, 306, 341, 535, 540`. +- **Current**: `.keyboardShortcut(.return, modifiers: .command)`, `[.command, .shift]`, `Cmd+.`, `Cmd+=`, `Cmd+-` all hardcoded. +- **HIG says**: Same as above - inconsistent customization story. +- **Fix**: Add corresponding `ShortcutAction` cases (`.executeAllStatements`, `.cancelQuery`, `.makeTextBigger`, `.makeTextSmaller`) and route through `KeyboardSettings`. +- **Effort**: M + +### [P1] `KeyCombo.systemReserved` list is incomplete + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:360-376`. +- **Current**: 15 reserved chords listed. +- **HIG says**: macOS reserves many more system chords for accessibility (VoiceOver `Cmd+F5`, Zoom `Cmd+Option+8`/`Cmd+Option+=`/`Cmd+Option+-`, Reduce/Increase Contrast `Cmd+Option+Ctrl+,`/`.`), Mission Control (`Ctrl+UpArrow`, etc.), Spaces (`Ctrl+LeftArrow`/`RightArrow`), and the Color Picker (`Cmd+Shift+C`). +- **Native examples**: Apple's "Keyboard Shortcuts" pane in System Settings is the authoritative list. +- **Fix**: Expand the list. At minimum add: `Cmd+F5` (VoiceOver), `Cmd+Option+8`, `Cmd+Option+Ctrl+,`/`.`, `Ctrl+UpArrow`, `Ctrl+DownArrow`, `Ctrl+LeftArrow`, `Ctrl+RightArrow`, `Cmd+Ctrl+C` (Color Picker - which is currently used by `.switchConnection`). +- **Effort**: S + +### [P1] `Cmd+Option+I` for Inspector overlaps with Safari's "Show Web Inspector" + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:499`. +- **Current**: `.toggleInspector: KeyCombo(key: "i", command: true, option: true)`. +- **HIG says**: `Cmd+Option+I` is Safari Web Inspector. Most users have it bound system-wide via developer tools enable. Inside TablePro this is fine, but be aware. +- **Native examples**: Xcode "Show Inspectors" `Cmd+Option+0`. Pages "Show Inspector" `Cmd+Option+I`. So Apple's apps disagree. +- **Fix**: Hold. `Cmd+Option+I` is acceptable. + +### [P1] "Truncate Table" lives in Edit menu, not in a database-specific menu + +- **File**: `TablePro/TableProApp.swift:451-456`. +- **Current**: "Truncate Table" is a row in the Edit menu's row-operations cluster. +- **HIG says**: Edit menu is for Cut/Copy/Paste/Find/Undo/Redo, not destructive table-level operations. HIG: "Group menu items by the kind of action they perform." +- **Native examples**: TablePlus places destructive ops on the table in a per-table context menu, not in Edit. +- **Fix**: Remove from Edit menu, leave only in the sidebar context menu (where it already lives via `SidebarContextMenu`). Or move to a top-level "Database" menu (see Query-menu split finding). +- **Effort**: S + +--- + +## P2 — Polish, label wording, separators, ellipses + +### [P2] "Manage Connections" missing ellipsis (folded into existing rename to "New Connection...") + +- See P1 entry above. + +### [P2] "Open Database..." ellipsis is correct, but the action does not actually open a file - it opens an in-app sheet + +- **File**: `TablePro/TableProApp.swift:216-220`. +- **Current**: "Open Database..." opens the database switcher sheet. +- **HIG says**: Ellipsis is fine because the user must pick a database. +- **Note**: Confirming. The bigger issue (label wording) is P0. + +### [P2] "Documentation" (in Help menu) lacks any indication that it opens a web URL + +- **File**: `TablePro/TableProApp.swift:588-590`. +- **Current**: "Documentation" with no leading icon, no trailing arrow, no ellipsis. +- **HIG says**: Apple's help-menu items that link out usually use unadorned text. No change needed; just noting that `tablepro.app` and `docs.tablepro.app` open in the browser silently. `NSWorkspace.shared.open(...)` on an `https://` URL is the correct approach. +- **Note**: No change. + +### [P2] "Report an Issue..." ellipsis is correct (opens FeedbackWindowController sheet) + +- **File**: `TablePro/TableProApp.swift:600-602`. +- **Note**: Already correct. + +### [P2] "About TablePro" item bundled with `Check for Updates...` and `MCPServerMenuItem` + +- **File**: `TablePro/TableProApp.swift:145-178`. +- **Current**: `CommandGroup(replacing: .appInfo)` puts About + Check for Updates... + Divider + MCP Status into the App menu. +- **HIG says**: App menu standard order: About App, Settings..., Services, Hide App, Hide Others, Show All, Quit App. "Check for Updates..." is a common third-party addition, placed right after About. The MCP server status item is unusual at this level - it's tool / dev-feature, and should live under Services or its own menu. +- **Native examples**: Sparkle apps: About, Check for Updates..., separator, Settings, Services... TablePro almost matches. +- **Fix**: Keep About + Check for Updates... in the App menu. Move "MCP Server Status" to a non-App-menu location (Help menu, or a new "Developer" menu). +- **Effort**: S + +### [P2] "MCP Server: Running (X clients)" label changes between launches and clutters the App menu + +- **File**: `TablePro/TableProApp.swift:687-712`. +- **Current**: Live-updating MCP server status item in App menu. +- **HIG says**: HIG: "Avoid showing dynamic status in menu titles." +- **Fix**: Move to a status-bar icon (NSStatusItem) or to Settings. Keep a static "Manage MCP Server..." menu entry instead. +- **Effort**: M + +### [P2] "Save as Favorite" appearance in Query menu lacks ellipsis but opens a sheet + +- **File**: `TablePro/TableProApp.swift:358-360`. +- **Current**: "Save as Favorite" - no ellipsis. +- **HIG says**: Action opens a sheet asking for a name. HIG mandates ellipsis. +- **Fix**: Rename to "Save as Favorite...". +- **Effort**: S + +### [P2] "View ER Diagram" lacks parallel structure with "Server Dashboard" + +- **File**: `TablePro/TableProApp.swift:514-522`. +- **Current**: "View ER Diagram" vs "Server Dashboard" (no verb). +- **HIG says**: Sibling menu items should follow the same grammatical pattern. Either both verb-noun or both noun. +- **Native examples**: Xcode View > Show Activity / Show Issues / Show Reports - parallel. +- **Fix**: "Show ER Diagram" / "Show Server Dashboard". Or drop "View" so both are noun-only. +- **Effort**: S + +### [P2] "Open Terminal" has no ellipsis - implies an immediate action, but in some configurations it opens an SSH credential picker + +- **File**: `TablePro/TableProApp.swift:524-528`. +- **Current**: "Open Terminal" - no ellipsis. +- **HIG says**: If the action takes additional input (SSH credentials, host pick), use ellipsis. +- **Fix**: Verify path. If it always opens directly, no change. If it ever requires input, add ellipsis. +- **Effort**: S + +### [P2] Welcome window context-menu "Copy Connection String" / "Copy TablePro Link" / "Copy as JSON" -- inconsistent capitalization + +- **File**: `TablePro/Views/Connection/WelcomeContextMenus.swift:126,134,141`. +- **Current**: "Copy Connection String", "Copy TablePro Link", "Copy as JSON". +- **HIG says**: Title case everywhere. "as" in the middle of "Copy as JSON" is HIG-correct lowercase preposition. Actually fine. The label is fine. +- **Note**: No change. + +### [P2] Welcome window "iCloud Sync" toggle copy: "Include in iCloud Sync" / "Exclude from iCloud Sync" + +- **File**: `TablePro/Views/Connection/WelcomeContextMenus.swift:64-67,170-176`. +- **Current**: Two distinct labels (Include / Exclude) used as a toggle. +- **HIG says**: Toggling labels is correct. But "Include in" / "Exclude from" is wordy. Simpler would be "Sync to iCloud" / "Don't Sync to iCloud" or a direct binary "Sync This Connection". +- **Fix**: Consider tightening, but acceptable. +- **Effort**: S (defer) + +### [P2] Sidebar context menu mixes "Show Structure" with "View ER Diagram" + +- **File**: `TablePro/Views/Sidebar/SidebarContextMenu.swift:80-89`. +- **Current**: "Show Structure" and "View ER Diagram" sit adjacent with no divider. +- **HIG says**: Show vs View - inconsistent verbs. +- **Fix**: Pick one verb. "Show Structure" / "Show ER Diagram". +- **Effort**: S + +### [P2] Sidebar context menu "Create New Subgroup" lacks ellipsis but renames inline (probably correct) + +- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:537-541`. +- **Current**: "New Subgroup" - inline rename in tree. +- **HIG says**: When the new entity is created and immediately ready for inline rename, no ellipsis is appropriate (Finder New Folder). +- **Note**: Correct. + +### [P2] Help menu "TablePro Website" bare URL action lacks an indicator that it's external + +- **File**: `TablePro/TableProApp.swift:584-586`. +- **Current**: "TablePro Website" with no symbol. +- **HIG says**: Apple's Help menu generally uses bare text for external URLs. No HIG violation. +- **Note**: No change. + +### [P2] Edit > Find (`Cmd+F`) has no in-menu indicator that it routes to the editor's Find bar (vs grid search) + +- **File**: `TablePro/TableProApp.swift:430-433`. +- **Current**: `EditorEventRouter.shared.showFindPanelForKeyWindow()` - editor-only. +- **HIG says**: A single Find item that only finds in one of multiple focusable views is ambiguous. Most users hitting `Cmd+F` over the data grid will expect to filter rows. +- **Fix**: Route `Cmd+F` through the responder chain (`performTextFinderAction:`) so the focused view chooses; in the data grid, that should bring up the filter panel. +- **Effort**: M + +### [P2] Editor right-click "Format SQL" has no shortcut shown, even though `.formatQuery` (`Cmd+Shift+L`) is bound + +- **File**: `TablePro/Views/Editor/AIEditorContextMenu.swift:53-62`. +- **Current**: `keyEquivalent: ""` - no key equivalent shown in the context menu. +- **HIG says**: Showing key equivalents in context menus helps users learn the shortcut. +- **Native examples**: Finder context menu "Get Info" shows `Cmd+I`. Mail context menu "Reply" shows `Cmd+R`. +- **Fix**: Set `keyEquivalent` and `keyEquivalentModifierMask` on the NSMenuItem. +- **Effort**: S + +### [P2] Data grid row context menu "Copy" does not show `Cmd+C`, "Paste" does not show `Cmd+V` + +- **File**: `TablePro/Views/Results/TableRowViewWithMenu.swift:39-99`. +- **Current**: All `keyEquivalent: ""` - no shortcuts visible in context menu. +- **HIG says**: Same as above. Useful affordance for keyboard learners. +- **Fix**: Set `keyEquivalent` for items that have a global shortcut. +- **Effort**: S + +### [P2] Terminal context menu shows Copy/Paste with empty `keyEquivalent` + +- **File**: `TablePro/Views/Terminal/TerminalTabContentView.swift:251-261`. +- **Current**: `keyEquivalent: ""`. +- **HIG says**: Same as above. +- **Fix**: Set `keyEquivalent`. +- **Effort**: S + +### [P2] Editor right-click "Save as Favorite..." has ellipsis (correct), but "Format SQL" does not (correct - it's an immediate action). Asymmetry could confuse users + +- **File**: `TablePro/Views/Editor/AIEditorContextMenu.swift:54,66`. +- **Current**: Mixed correctly. +- **Note**: Confirmed correct, no change. + +### [P2] AI right-click "Explain with AI" / "Optimize with AI" no ellipsis - actions stream output to a panel + +- **File**: `TablePro/Views/Editor/AIEditorContextMenu.swift:81,90`. +- **Current**: No ellipsis. +- **HIG says**: Streaming AI is closer to a chat than a dialog. No ellipsis is conventional in modern AI UIs. Borderline. +- **Note**: Hold. + +### [P2] Context menu "Set Value -> NULL / Empty / Default" submenu nests deeper than necessary + +- **File**: `TablePro/Views/Results/TableRowViewWithMenu.swift:135-167`. +- **Current**: Submenu with 1-3 items. +- **HIG says**: HIG: "Avoid one-item or two-item submenus." +- **Fix**: When only 1 entry would be shown (e.g. NOT NULL column with no default), inline as "Set Empty"; when 2+, keep submenu. Or always show all three with appropriate disabled states. +- **Effort**: S + +### [P2] Window menu's "Select Tab N" entries (Cmd+1..9) clutter the menu + +- **File**: `TablePro/TableProApp.swift:546-555`. +- **Current**: Nine permanent menu entries. +- **HIG says**: Apple's native macOS tabs auto-populate the Window menu with tab titles when `tabbingMode = .preferred`. TablePro is adding redundant tab-by-number entries. +- **Native examples**: Safari's Window menu lists tabs by title, not "Select Tab 1". Cmd+1..9 still works via `selectTab(_:)` system-wide. +- **Fix**: Remove the manual "Select Tab N" buttons. Let macOS's native tab handling fire `selectTab:` selectors. The menu becomes self-populating. +- **Effort**: S + +### [P2] No "Move Tab to New Window" / "Merge All Windows" entries + +- **File**: `TablePro/TableProApp.swift:544-578`. +- **Current**: Custom Window menu lacks the standard tab-management entries. +- **HIG says**: Apple's Window menu standard for tabbed apps: Show Previous Tab, Show Next Tab, Move Tab to New Window, Merge All Windows. SwiftUI / NSWindow injects most of these automatically when `tabbingMode = .preferred`. Verify presence. +- **Fix**: Verify with the running app. If missing, add via `NSWindow.moveTabToNewWindow:` and `NSWindow.mergeAllWindows:` selectors. +- **Effort**: S + +### [P2] `Cmd+Shift+P` for "Preview SQL" overlaps with the macOS "Page Setup" chord in document apps + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:468`. +- **Current**: `Cmd+Shift+P`. +- **HIG says**: `Cmd+Shift+P` = "Page Setup..." in print-aware apps. TablePro doesn't print, so safe in scope, but unusual. +- **Fix**: Hold. + +### [P2] `KeyCombo.cleared` sentinel value uses an empty `key` string and is conceptually fragile + +- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:519-526`. +- **Current**: Sentinel value with empty key. +- **HIG says**: Not a HIG concern, but worth flagging. A `nil` is more idiomatic. +- **Fix**: Refactor `KeyboardSettings.shortcuts` to `[String: KeyCombo?]` so the absent state is the empty/nil case. +- **Effort**: M + +### [P2] Settings vs Preferences naming + +- **File**: `TablePro/TableProApp.swift:634-637`. +- **Current**: SwiftUI's `Settings { ... }` scene auto-injects "Settings..." in the App menu (macOS 13+). +- **HIG says**: Correct. Apple renamed "Preferences" to "Settings" in macOS 13. +- **Note**: No change. + +### [P2] Custom about panel has links separated by ` | ` — not a HIG style + +- **File**: `TablePro/TableProApp.swift:159-164`. +- **Current**: Uses `" | "` separator between credits links. +- **HIG says**: Apple's about panels (Sparkle apps included) use separate lines or a dedicated credits view. +- **Native examples**: Most Sparkle apps put each link on its own line. +- **Fix**: Stack vertically using `\n` separators in the attributed string. +- **Effort**: S + +--- + +## Summary + +| Severity | Count | Areas covered | +| --- | --- | --- | +| **P0** | 13 | App menu, File menu, View menu, Edit menu, Query menu, key conflicts | +| **P1** | 22 | Wording, customization gaps, menu placement, label parallelism, system-reserved list | +| **P2** | 22 | Polish, ellipses, key equivalents in context menus, About panel | +| **Total** | **57** | | + +By area: + +| Area | P0 | P1 | P2 | +| --- | --- | --- | --- | +| App menu | 0 | 0 | 3 | +| File menu | 5 | 4 | 1 | +| Edit menu | 1 | 4 | 4 | +| View menu | 4 | 3 | 1 | +| Query menu | 2 | 5 | 3 | +| Window menu | 1 | 1 | 2 | +| Help menu | 0 | 2 | 1 | +| Keyboard defaults (`KeyboardShortcutModels.swift`) | 6 | 6 | 1 | +| Context menus (welcome, sidebar, data grid, editor, terminal, results, history) | 0 | 5 | 6 | +| Cross-cutting (settings infrastructure, sentinel) | 0 | 2 | 2 | + +Top-priority fixes (suggest doing first): + +1. Rebind `Cmd+D` to Duplicate (move Save as Favorite to Cmd+Shift+D). +2. Drop the `Cmd+Y` mapping (Quick Look conflict). +3. Drop the `Cmd+Option+Delete` mapping (Empty Trash conflict). +4. Drop the `Cmd+Ctrl+C` mapping (Color Picker conflict). +5. Drop the `Cmd+L` mapping (URL bar conflict; it also collides with `Cmd+Shift+L = Format Query`). +6. Move Refresh, Switch Connection, Quick Switcher out of the Query menu. +7. Toggle Sidebar / Inspector / Filters / History / Results: switch labels Show/Hide. +8. Re-add a real "New Window" item with a non-`Cmd+N` shortcut. +9. Fix label parallelism: "Save Changes" -> "Save", "Toggle X" -> "Show/Hide X", "View ER Diagram" / "Server Dashboard" parallel. +10. Add Find submenu (Find, Find Next, Find Previous, Use Selection for Find). diff --git a/docs/refactor/hig-audit/02-windows-interactions.md b/docs/refactor/hig-audit/02-windows-interactions.md new file mode 100644 index 000000000..682e3b373 --- /dev/null +++ b/docs/refactor/hig-audit/02-windows-interactions.md @@ -0,0 +1,394 @@ +# Windows, Tabs, Sheets, and Interactions Audit + +**Agent**: window-interaction-auditor +**Date**: 2026-05-01 +**Scope**: Main `TablePro/` target. AppKit + SwiftUI hybrid. Source: `TablePro/Core/Services/Infrastructure/*`, `TablePro/Views/**`, `TablePro/AppDelegate.swift`, `TablePro/TableProApp.swift`. + +--- + +## P0 — Broken native contracts + +### [P0] Window-modal sheets used for sheets-of-sheets across export, import, AI provider, database switcher + +- **File**: `TablePro/Views/Export/ExportDialog.swift:97`, `:132`, `:146`; `TablePro/Views/Import/ImportDialog.swift:95`, `:103`, `:112`; `TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift:117`, `:120`; `TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift:220`, `:223` +- **Current**: A sheet (`ExportDialog`, `ImportDialog`, `DatabaseSwitcherSheet`, `ConnectionFormView`) presents another sheet on top of itself: `LicenseActivationSheet`, `ExportProgressView`, `ExportSuccessView`, `ImportProgressView`, `ImportSuccessView`, `ImportErrorView`, `CreateDatabaseSheet`, `DropDatabaseSheet`, URL import. SwiftUI does present these stacked, but the result is a sheet presented from a sheet, which is what HIG explicitly tells you not to do. +- **HIG says**: "Avoid presenting a sheet from a sheet. Generally, only one sheet is visible at a time. Avoid letting people open a sheet from within a sheet, because hierarchies of sheets can become confusing." (Sheets — macOS). +- **Native examples**: Mail's compose window swaps inline panels for "send later" / attachment errors. Xcode's project settings push the sub-screens inside the same sheet (NavigationStack). Notes' export uses an NSSavePanel attached to the document, not chained sheets. +- **Fix**: + - Replace stacked progress/success sheets with **inline state inside the parent sheet** (a single sheet that swaps between configure → progress → success/error, like Mail's send sheet). + - For `ExportDialog → LicenseActivationSheet` and `ImportDialog → LicenseActivationSheet`: dismiss the parent sheet first, then present activation. Or push activation as a NavigationStack screen inside the parent. + - For `DatabaseSwitcherSheet → CreateDatabaseSheet / DropDatabaseSheet`: turn these into a NavigationStack push inside the switcher (it already has the room — 420×480), or close the switcher and open the create/drop dialog standalone. +- **Effort**: M (each dialog), L overall (5 dialogs) + +### [P0] QuickSwitcher is a window-modal sheet, but it should be a floating panel + +- **File**: `TablePro/Views/QuickSwitcher/QuickSwitcherView.swift:14-249`, presented from `TablePro/Views/Main/MainContentView.swift:204-213` +- **Current**: `QuickSwitcherSheet` is presented via `.sheet(item: coordinator.activeSheet)` and dims the parent window. It is a Spotlight/Cmd+P-style "go to symbol" search. +- **HIG says**: "Use a popover or a panel for short, focused, transient input. A modal sheet stops everything else." Spotlight, Xcode's "Open Quickly", Safari's Tab Switcher, Notes' "Find Note" are all `NSPanel` or popover, not window-modal sheets. +- **Native examples**: Xcode "Open Quickly" (Cmd+Shift+O) — floats above the window, doesn't dim it, dismisses on focus loss. Spotlight, Raycast. +- **Fix**: Promote to `NSPanel` (`.titled, .nonactivatingPanel`, `.fullSizeContentView`) shown via a small factory, similar to `FeedbackWindowController`. Center over the key window, dismiss on Escape or focus loss. Remove from `ActiveSheet` enum. +- **Effort**: M + +### [P0] QuickSwitcher Cmd+1...Cmd+9 selection is not handled — only opening selected item + +- **File**: `TablePro/Views/QuickSwitcher/QuickSwitcherView.swift:69-82` +- **Current**: Only `.return`, Ctrl+J/N/K/P, and the search field arrow keys move/select. No way to jump directly with Cmd+1...Cmd+9 like Spotlight or VS Code Quick Open. +- **HIG says**: Spotlight-style switchers consistently support Cmd+digit jumps for the top N results. +- **Native examples**: Spotlight, Safari Tab Switcher (Cmd+1...Cmd+9 jumps to that tab), Xcode "Open Quickly". +- **Fix**: Add `.onKeyPress` handlers for digit keys with `.command` modifier that select and open the Nth item. +- **Effort**: S + +### [P0] FeedbackWindowController uses NSPanel, but tied to NSApp.keyWindow lifecycle through `viewModel.captureTargetWindow` + +- **File**: `TablePro/Views/Feedback/FeedbackWindowController.swift:18-64` +- **Current**: `showFeedbackPanel` resolves `NSApp.keyWindow` once at open time and stashes it on `viewModel.captureTargetWindow`. If the user switches windows / closes the original window before submitting feedback, capture target is stale or nil. The panel is a singleton, so opening once-then-switching-windows reuses the old captureTargetWindow. +- **HIG says**: Panels are utility windows whose context can update as the user changes the underlying document or window. They should resolve their target lazily when the action runs (on submit) rather than freezing it at open. +- **Native examples**: Mail's "Report Junk" sheet attaches to the active message window. Xcode's "Report a Bug" reads the front document at submit time. +- **Fix**: Resolve `captureTargetWindow` at submit time, not at open time, by inspecting `NSApp.mainWindow` (or by binding the panel to the front main window via parentWindow). +- **Effort**: S + +### [P0] EditorWindow.performClose collapses last tab to empty state instead of closing window + +- **File**: `TablePro/Core/Services/Infrastructure/TabWindowController.swift:27-36`; `TablePro/Views/Main/MainContentCommandActions.swift:352-373` +- **Current**: Cmd+W on a window with one tab and zero query tabs → window closes. With one window and one or more open query tabs → calls `closeTab()` which clears all tabs and leaves an empty "no tabs" main window. Effectively, Cmd+W twice is required to close a single-tab single-window setup. +- **HIG says**: "Cmd+W closes the focused window. If the window is a tab inside a tabbed window group, Cmd+W closes that tab. If only one tab remains, the window itself closes." Cmd+W must never leave an empty window. +- **Native examples**: Safari, Notes, Xcode — Cmd+W closes the tab; if only one tab remains, the window closes. Cmd+Option+W closes all tabs/windows. +- **Fix**: + - Cmd+W should close the active tab if multiple query tabs exist, otherwise close the window. The existing code path is inverted for the single-tab case. + - Reserve "no tabs visible" as a transient empty state, not a destination Cmd+W can drive the window into. + - On the last window's last tab close, fall through to the standard "show welcome" behavior (already in `AppDelegate.windowWillClose`). +- **Effort**: M + +### [P0] No "Show All Tabs" / Cmd+Shift+\\ support + +- **File**: Menu commands in `TablePro/TableProApp.swift:543-578` +- **Current**: Only Cmd+1...Cmd+9, Cmd+Shift+[, Cmd+Shift+] are wired. There is no menu item or shortcut for `NSWindow.toggleTabOverview(_:)` (Show All Tabs / Cmd+Shift+\\), which Safari/Notes/Finder/Mail all expose. +- **HIG says**: When using native window tabs, the standard "View > Show All Tabs" / Cmd+Shift+\\ binding is part of the contract. Users expect it. +- **Native examples**: Safari, Notes, Finder, Mail — every native tabbed app supports `toggleTabOverview`. +- **Fix**: Add `Button("Show All Tabs")` to `CommandGroup(after: .windowArrangement)` in `AppMenuCommands` that calls `NSApp.sendAction(#selector(NSWindow.toggleTabOverview(_:)), to: nil, from: nil)` with `.keyboardShortcut("\\", modifiers: [.command, .shift])`. +- **Effort**: S + +### [P0] No "Move Tab to New Window" command + +- **File**: Menu commands in `TablePro/TableProApp.swift`, no menu item; `EditorWindow` does not override or expose `moveTabToNewWindow:`. +- **Current**: There is no menu, context menu, or shortcut to break a tab out into its own window. Native window tabs support `NSWindow.moveTabToNewWindow(_:)` but it's not surfaced. +- **HIG says**: "When a window can have tabs, the system adds Move Tab to New Window and Merge All Windows to the Window menu automatically. If you replace the menu, you must reinclude these items." +- **Native examples**: Safari, Finder, Notes — both items appear in Window menu. +- **Fix**: Add to the Window menu: + - "Move Tab to New Window" → `NSWindow.moveTabToNewWindow(_:)` + - "Merge All Windows" → `NSWindow.mergeAllWindows(_:)` + Both actions should validate against `validateUserInterfaceItem(_:)` (only enabled when the key window has siblings or is part of a tab group). +- **Effort**: S + +### [P0] Multiple windows for the same connection silently share toolbar/coordinator state + +- **File**: `TablePro/Core/Services/Infrastructure/WindowManager.swift:23-85`; `TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift:32` +- **Current**: `WindowManager.openTab` adds new windows to a tab group keyed by `tabbingIdentifier(for: connectionId)` (or "com.TablePro.main" if `groupAllConnectionTabs` is on). With "groupAllConnectionTabs" off, each connection gets its own tab group, but separate tab groups for the same connection are possible if a user drags one tab out. Toolbar identifiers use `UUID()` (line 49) so each window has its own toolbar — good — but the coordinator is per-window, and multiple coordinators for the same connection share `DatabaseManager.shared.activeSessions[connectionId]`. There's no documented behavior for "should the same connection live in two windows". +- **HIG says**: A document/connection is the user's mental unit. Multi-window per document is allowed (Pages does it) but each window must look first-class — same toolbar, same data, no surprising side effects. +- **Native examples**: Pages, Notes, Xcode — multi-window per document is consistent; closing one window does not close the document, closing the last window does. +- **Fix**: Decide and document one of the following: + 1. **Single window per connection** (TablePlus model) — block `moveTabToNewWindow:` and gate "Open Connection" to focus an existing window. Simpler. + 2. **Multi-window per connection** (Pages model) — verify all per-window state (filter panel, history panel, change manager) is window-scoped (today some of these are not — `FilterStateManager` is created per coordinator, but `DataChangeManager`'s relationship to the underlying session needs review). + + Either is fine, but pick one and enforce. The current state is "accidentally allowed multi-window". +- **Effort**: L + +--- + +## P1 — Non-idiomatic patterns + +### [P1] Welcome window blocks miniaturize and zoom — unnecessary restriction + +- **File**: `TablePro/Core/Services/Infrastructure/WelcomeWindowFactory.swift:47-49` +- **Current**: `standardWindowButton(.miniaturizeButton)?.isHidden = true`, `standardWindowButton(.zoomButton)?.isHidden = true`, `collectionBehavior.insert(.fullScreenNone)` +- **HIG says**: "Don't disable the standard window buttons unless your window genuinely shouldn't be miniaturized." A welcome window can be miniaturized (Xcode, Pages, Sketch all do). +- **Native examples**: Xcode's welcome window — minimizes, can't be resized, can't fullscreen (those are correct restrictions). Sketch's welcome — same. None hide the close button row entirely. +- **Fix**: Re-enable miniaturize. Keep `.fullScreenNone`. Hiding zoom is acceptable for a fixed-size window, but consider showing it greyed-out instead of hidden (matches Xcode behavior). +- **Effort**: S + +### [P1] Connection form window disables miniaturize and zoom and removes from style mask + +- **File**: `TablePro/Core/Services/Infrastructure/ConnectionFormWindowFactory.swift:52-55` +- **Current**: Sets `miniaturizeButton.isEnabled = false`, `zoomButton.isEnabled = false`, then `styleMask.remove(.miniaturizable)`. The first two lines disable greyed buttons; the third removes the affordance entirely so the button hole disappears. +- **HIG says**: Connection editing is a long-running task — users want to alt-tab away or minimize. +- **Native examples**: Notes new note, Mail compose, System Settings panes — all minimizable. +- **Fix**: Drop both blocks. Allow miniaturize. Allow zoom (the form is resizable already). Add `.fullScreenAuxiliary` so the form opens above a fullscreen main window when triggered from inside it. +- **Effort**: S + +### [P1] FavoriteEditDialog is a sheet but should be a panel + +- **File**: `TablePro/Views/Sidebar/FavoriteEditDialog.swift:62-156`, presented via `.sheet(item:)` from `FavoritesTabView.swift:52` and `MainEditorContentView.swift:95` +- **Current**: A 480-wide form-sheet that includes a TextEditor (160 px tall) plus name/keyword/folder/global. Used both from the sidebar (favorites tab) and the editor (Save as Favorite from the query editor). +- **HIG says**: A sheet is appropriate when the action is tied to a single document. This form is a stand-alone object editor — it could outlive the current window context. Other native object-editor flows (Calendar new event, Reminders detail, Photos info) are panels or popovers. +- **Native examples**: Calendar's New Event panel, Reminders' detail panel. +- **Fix**: Decision call. If the favorite is always tied to the current connection, a sheet is fine. If it's a global object (Global toggle exists at line 109), a panel is better. Lean toward panel since the same dialog is reachable from multiple places. +- **Effort**: M + +### [P1] License activation as a sheet, but reachable from many places — duplicates and stacks + +- **File**: `TablePro/Views/Settings/LicenseActivationSheet.swift`, presented from `SafeModeBadgeView.swift:71`, `ProFeatureGate.swift:28`, `SyncStatusIndicator.swift:33`, `ExportDialog.swift:97`, `ConnectionFormView+GeneralTab.swift:223`, `WelcomeWindowView.swift:91` +- **Current**: Six different presentation sites. If the user has the export sheet open and clicks "Activate License" inside it, the activation sheet stacks on top of the export sheet (which is a sheet on the main window). Same for the connection form. +- **HIG says**: A licensing dialog is an app-level action. It is not tied to any particular document. It belongs in Settings, or as a panel/window reachable from any context, but not as a sheet that stacks. +- **Native examples**: Sparkle's update window is a panel. Affinity, Pixelmator Pro use a panel for license activation. +- **Fix**: Convert `LicenseActivationSheet` to an NSPanel reachable via `LicenseWindowController.shared.show()`. Each of the six call sites simply triggers the controller. +- **Effort**: M + +### [P1] Sheets sized with hard-coded `.frame(width:)` lose the user's resize state + +- **File**: `MaintenanceSheet.swift:75` (`.frame(width: 420)`), `TableOperationDialog.swift:165` (`.frame(width: 320)`), `LicenseActivationSheet.swift:83` (`.frame(width: 400)`), `MCPTokenRevealSheet.swift:40` (`.frame(width: 540, height: 520)`), `DatabaseSwitcherSheet.swift:110` (`.frame(width: 420, height: 480)`), `QuickSwitcherView.swift:59` (`.frame(width: 460, height: 480)`), `FavoriteEditDialog.swift:132` (`.frame(width: 480)`), `AIProviderDetailSheet.swift:91` (`.frame(minWidth: 520, minHeight: 480)`) +- **Current**: Most sheets are fixed-size. Only `AIProviderDetailSheet` allows resize. +- **HIG says**: "Use a sheet that's small enough to fit the content but large enough that people don't have to scroll a lot. If the content can be longer, allow the sheet to grow." Sheets containing a TextEditor (FavoriteEditDialog, MCP token, JSON viewer) should be resizable. +- **Native examples**: Mail's compose, Notes' share sheet — resizable. +- **Fix**: Add `.frame(minWidth:idealWidth:maxWidth:)` and rely on the platform to remember user-set size where the sheet is editor-like (any sheet containing a TextEditor or a long form). +- **Effort**: S per dialog + +### [P1] No drag-out support on data grid rows — only intra-grid reorder + +- **File**: `TablePro/Views/Results/DataGridView+RowActions.swift:175-227` +- **Current**: `pasteboardWriterForRow` writes `com.TablePro.rowDrag` (custom UTI), TSV, and HTML. `validateDrop` rejects any drag whose source is not the same NSTableView (line 203: `info.draggingSource as? NSTableView === tableView`). So you cannot drag selected rows into Numbers, Excel, a text editor, Mail, or Finder. +- **HIG says**: "Where dragging makes sense, support it broadly. Tables and lists usually allow dragging selected rows out of the app." TSV+HTML on the pasteboard would be enough to drag rows into Numbers as a table. +- **Native examples**: Numbers, Finder list view, Mail attachment list — all support drag out. +- **Fix**: Drop the `info.draggingSource as? NSTableView === tableView` check from `validateDrop`. Implement `tableView(_:writeRowsWith:to:)` (or its newer pasteboardWriter equivalent) so that dragging out writes both the custom `rowDrag` type (intra-grid moves), and standard `string` + `html` types (cross-app drops). External apps will see TSV/HTML; internal moves still see the custom type. +- **Effort**: M + +### [P1] No drop-onto-window for SQL files and CSVs + +- **File**: No NSWindow drop target outside `Views/Settings/Plugins/InstalledPluginsView.swift:46` (which accepts `.fileURL` for plugin install) and `Views/Feedback/FeedbackView.swift:122`. +- **Current**: Dragging a `.sql`, `.csv`, or `.json` file onto the main editor window does nothing. The Open command exists, but drag-drop is the macOS norm. +- **HIG says**: "Documents that can be opened by your app should accept drop." Apple's CFBundleDocumentTypes is registered (per the recent commit referenced in git log) but the receiving window doesn't implement drop. +- **Native examples**: Xcode (drag a `.swift` onto the project), TextEdit (drag a `.txt` onto the window), VS Code, Sublime Text. +- **Fix**: Add `.onDrop(of: [.fileURL], isTargeted: nil) { providers in ... }` at the level of the editor's main content view (or implement `NSDraggingDestination` on the `EditorWindow`'s contentView). Route through the same path as File > Open. +- **Effort**: M + +### [P1] Welcome window has no drop target for `.tablepro` import files + +- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift` +- **Current**: Has `.fileImporter` for `.tableproConnectionShare` (line 137-145) but no `onDrop`. Users with a `.tablepro` file in Finder can't drag it onto the welcome window. +- **HIG says**: Anywhere you accept a file via "Import...", you should accept the same file via drop. +- **Native examples**: Apple Music welcome (drag .m4a), iMovie welcome (drag .mp4). +- **Fix**: Add `.onDrop(of: [.tableproConnectionShare], isTargeted: ...)` on the welcome view that routes to the same `vm.activeSheet = .importFile(url)` path. +- **Effort**: S + +### [P1] Multi-select in WelcomeWindowView connection list does not follow Finder selection conventions + +- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:280-326` +- **Current**: Selection is implemented as a `Set` on the SwiftUI `List`. Cmd+A (line 316-320) selects all visible. Cmd+Click and Shift+Click are handled by the `List` natively, so this is OK at first glance — but the connection rows themselves are wrapped in `WelcomeConnectionRow` with custom hit testing. Connect-on-double-click is wired through `DoubleClickDetector` (used elsewhere in `connectionList` rendering). +- **HIG says**: Lists with selection follow Finder/Mail conventions: click selects, Shift+click extends, Cmd+click toggles, double-click activates, Return activates the focused row. +- **Native examples**: Finder, Mail, Notes. +- **Fix**: Verify Shift+Click range selection works against the visible flat list (not just the in-group order). Verify Cmd+Click toggles a single row in/out of the selection without clearing other selections. Add tests if missing. +- **Effort**: S to verify, M if broken + +### [P1] Sidebar table list uses `.contextMenu` only — important destructive actions hidden + +- **File**: `TablePro/Views/Sidebar/SidebarView.swift:197-216`, `SidebarContextMenu.swift:35-` +- **Current**: "Drop View", "Truncate Table" (via `TableOperationDialog`), and others live only in the right-click context menu. There's no menu-bar equivalent, so a user without a right mouse button (or who has never right-clicked the sidebar) won't discover them. +- **HIG says**: "If a context menu has actions not available elsewhere, it's a discoverability problem. Important destructive actions should also live in the menu bar (with a sensible disable rule)." +- **Native examples**: Finder — every "right-click new folder" action is also under File or Edit. Notes — delete note appears in both the context menu and Edit menu. +- **Fix**: Promote drop/truncate/edit-view-definition into the Edit menu under a "Tables" submenu, gated on `actions.hasTableSelection`. +- **Effort**: M + +### [P1] DatabaseSwitcherSheet drop key uses `delete` (forward delete on a Mac Magic Keyboard) instead of backspace + +- **File**: `TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift:145-149` +- **Current**: `.onKeyPress(.delete) { ... initiateDropForSelected() }` — `KeyEquivalent.delete` in SwiftUI maps to the forward-delete key (fn+delete on most Apple keyboards). +- **HIG says**: Lists conventionally use **Backspace** (the regular Delete key, character `\u{7F}`) for "remove selected", not forward Delete. +- **Native examples**: Finder uses Cmd+Delete (backspace); Mail uses Delete (backspace); Music uses Delete (backspace). +- **Fix**: Replace with `.onKeyPress(characters: .init(charactersIn: "\u{7F}\u{08}"), phases: .down) { ... }` matching the pattern already used in `WelcomeWindowView.swift:229` and `:308`. Consider gating on `.command` modifier (Cmd+Delete) since dropping a database is unusually destructive. +- **Effort**: S + +### [P1] `MaintenanceSheet` Execute button uses `.defaultAction` keyboard shortcut for a destructive operation + +- **File**: `TablePro/Views/Sidebar/MaintenanceSheet.swift:66-71` +- **Current**: Execute (which can run `VACUUM FULL` and rewrite a table while blocking access) is bound to Return via `.keyboardShortcut(.defaultAction)`. +- **HIG says**: For destructive defaults, either make Cancel the default action and require the user to click Execute, or attach Return to a non-destructive primary action. Apple's NSAlert convention places Cancel last (right) and emphasized via Escape; destructive primaries are usually distinct from the default. +- **Native examples**: Finder Empty Trash — Empty is on the right but Cancel is highlighted as the default. System Settings Reset — same pattern. +- **Fix**: Mark Cancel as `.keyboardShortcut(.cancelAction)` (already done at `:65`) and remove the `.defaultAction` from Execute. Force a deliberate click to execute, or require holding Option to enable Return-to-execute. Consider also adding `.role(.destructive)` for the right styling. +- **Effort**: S + +### [P1] `TableOperationDialog` Drop button uses `.keyboardShortcut(.return, modifiers: [])` and is destructive default + +- **File**: `TablePro/Views/Sidebar/TableOperationDialog.swift:154-162` +- **Current**: Drop button is destructive but bound to Return as the default action. Cancel has no `.cancelAction`. +- **HIG says**: Same as above — Cancel should be default for destructive sheets, or at minimum no Return shortcut. +- **Native examples**: Finder's "Move to Trash" prompt makes Cancel the default; Trash is on the right. +- **Fix**: Add `.keyboardShortcut(.cancelAction)` to Cancel (line 148), remove the Return shortcut from Drop, mark Drop as `.role(.destructive)`. +- **Effort**: S + +### [P1] AlertHelper destructive confirms put confirm button first (left) and don't use .destructive role + +- **File**: `TablePro/Core/Utilities/UI/AlertHelper.swift:24-39`, `:43-65`, `:130-163` +- **Current**: `confirmDestructive` adds the confirm button first (`addButton(withTitle: confirmButton)`), which puts it on the **right** in macOS 11+ NSAlert layout (NSAlert lays out buttons right-to-left in addition order). Confirm/Execute is on the right, Cancel on the left, but `confirmDestructive` does not call `hasDestructiveAction = true` on the destructive button. `confirmSaveChanges` does set it on Don't Save (line 142), so the pattern is known. +- **HIG says**: Destructive buttons should be on the **leading** (left) side, with Cancel as the default on the right. NSAlert's `hasDestructiveAction = true` puts the button on the left in macOS 11+. +- **Native examples**: Finder's "Move to Trash", Mail's "Delete Message" — destructive action on the left, Cancel on the right (default). +- **Fix**: + - Set `hasDestructiveAction = true` on the destructive button in every confirm helper. + - Audit each call site — many places (`confirmDangerousQueryIfNeeded`, `confirmDiscardChanges`) intend the action button to be destructive. +- **Effort**: S + +### [P1] Settings TabView at fixed 720×500 — lots of content gets clipped on small screens + +- **File**: `TablePro/Views/Settings/SettingsView.swift:64` +- **Current**: `.frame(width: 720, height: 500)` regardless of which tab is selected. +- **HIG says**: Preferences/settings windows should size to their content. Each pane has different needs. +- **Native examples**: System Settings, Xcode Settings, Notes Settings — each pane sizes itself. +- **Fix**: Drop the fixed frame. Add `.frame(minWidth: 600)` and let each tab grow vertically. Or use `Settings` scene with per-tab `idealWidth/idealHeight`. +- **Effort**: S + +### [P1] AIProviderDetailSheet uses NavigationStack inside a sheet — odd nesting + +- **File**: `TablePro/Views/Settings/AIProviderDetailSheet.swift:52-91` +- **Current**: A sheet that wraps its body in `NavigationStack` purely to get a navigation title bar with Cancel/Save toolbar items. Presented from inside the Settings TabView (which is itself a sheet-like preferences window). +- **HIG says**: NavigationStack belongs inside a navigable container. A sheet can have a title via `.navigationTitle` only if the sheet's root is NavigationStack — but a single-screen sheet doesn't need the stack overhead. +- **Native examples**: Mail's compose sheet uses an explicit toolbar instead of NavigationStack. +- **Fix**: Replace the NavigationStack wrapper with a manual header strip (Cancel/title/Save buttons) or with the standard sheet button placement at the bottom. The existing `.toolbar` items will move into a footer HStack. +- **Effort**: S + +### [P1] Cmd+Shift+P (Preview SQL) opens a popover from a Toolbar button — popover anchor is fragile under window resize + +- **File**: `TablePro/Views/Toolbar/TableProToolbarView.swift:154-168` +- **Current**: The Preview SQL toolbar item wraps a Button in a VStack and attaches `.popover(isPresented:)` to the VStack. When the toolbar overflows into the chevron (narrow window), the popover anchor moves to the chevron menu, which can produce a misanchored popover. +- **HIG says**: Popovers should anchor to a stable visible control. A toolbar item can collapse, so the popover must be tolerant. +- **Native examples**: Safari's "Tab Group" toolbar popover uses `NSToolbarItem.itemIdentifier` and a sourceItemIdentifier to anchor correctly. +- **Fix**: Anchor the popover to the toolbar item identifier (NSToolbarItem itemIdentifier) via `popover(isPresented:attachmentAnchor:arrowEdge:)` with a fixed source. Or move the SQL preview into a sheet/panel since the content is rich. +- **Effort**: M + +### [P1] Result tab bar (custom `ResultTabBar`) duplicates native window-tab-bar styling but isn't native + +- **File**: `TablePro/Views/Results/ResultTabBar.swift:11-75` +- **Current**: A custom horizontal scrolling tab bar that visually mimics window tabs. It is for switching between multiple result sets within a single query tab — different conceptual layer than NSWindow tabs. +- **HIG says**: This is fine in principle (it's a sub-tab bar, like Xcode's "Issues / Errors / Warnings" segmented control), but the visual treatment is borrowed from window tabs which can mislead users. Consider using a `Picker(.segmented)` or NSSegmentedControl, both of which are unambiguous. +- **Native examples**: Xcode's debug tab bar uses segmented controls. Numbers/Pages chapter switches use a thinner accent bar. +- **Fix**: Either re-style with `Picker(.segmented)` style or accept a thinner pill design without rounded-rectangle backgrounds. Differentiate from NSWindow tabs. +- **Effort**: M + +### [P1] No "New Window" or "Open in New Window" affordance — only "New Tab" + +- **File**: `TableProApp.swift:204-228` (only "New Tab"), `WindowManager.swift:23-85` +- **Current**: Cmd+T → New Tab. There's no Cmd+N or any other shortcut for "open this connection in a new window separate from the current tab group". Combined with the auto-grouping logic in WindowManager, every connection-open ends up tabbed into an existing group. +- **HIG says**: Apps that support window tabs should also expose "New Window" — Cmd+N is conventional. +- **Native examples**: Safari (Cmd+N new window, Cmd+T new tab), Notes, Finder, Mail. +- **Fix**: Decide based on the multi-window-per-connection decision (see P0 above). If multi-window is allowed, expose Cmd+N as "New Window" (open the same connection without joining the existing tab group) — implementation: temporarily set `tabbingMode = .disallowed` for the new window. If single-window-per-connection is enforced, this finding becomes obsolete. +- **Effort**: M + +--- + +## P2 — Polish + +### [P2] DatabaseSwitcherSheet `current` badge uses lowercase string literal "current" — not localized, capitalization off + +- **File**: `TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift:236` +- **Current**: `Text("current")` — lowercase, in English only. +- **HIG says**: Localized, sentence-case for non-title text. +- **Fix**: `Text(String(localized: "Current"))`. +- **Effort**: S + +### [P2] FavoriteEditDialog "Save" / "Add" buttons need consistent verb + +- **File**: `TablePro/Views/Sidebar/FavoriteEditDialog.swift:124` +- **Current**: Button text alternates between `"Save"` (edit) and `"Add"` (new). Other forms use "Add" or "Create" — inconsistency across the app. +- **HIG says**: Form action verbs should be consistent — pick one of "Save" / "Add" / "Create" / "Done" and use it everywhere. +- **Fix**: Use "Add" for new + "Save" for edit. Cross-check `CreateGroupSheet`, `ConnectionFormView` Save/Add buttons. +- **Effort**: S + +### [P2] DataGrid drag pasteboard writer always sets `string` and `html` even for intra-grid moves + +- **File**: `TablePro/Views/Results/DataGridView+RowActions.swift:175-194` +- **Current**: Every drag carries TSV and HTML on the pasteboard, even though `validateDrop` blocks anything other than `rowDrag`. Wasted serialization on every drag. +- **HIG says**: N/A — this is a perf/correctness polish. +- **Fix**: Only set TSV+HTML when the drag is going to leave the table view (i.e. always, once cross-app drop is allowed — see P1 above). Until then, only set `rowDrag`. +- **Effort**: S + +### [P2] WelcomeWindowView `Frame(minWidth: 350)` on the right panel can clip column headers in narrow mode + +- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:271` +- **Current**: Right panel has `minWidth: 350`. Welcome window itself has `idealWidth: 700`. With 250-px left panel, this can cramp. +- **HIG says**: Resizing should not produce clipped UI. +- **Fix**: Either set a higher `minWidth` on the right panel, or add `idealWidth: 450` plus a higher minimum window width. +- **Effort**: S + +### [P2] `MCPTokenRevealSheet` is a sheet but contains lots of read-only setup info — could be a panel + +- **File**: `TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift:9-41` +- **Current**: 540×520 sheet showing token + setup snippets for three clients. The "this token will not be shown again" warning is critical, but once dismissed, the user often wants to copy the token while reading docs in another app — sheet blocks that. +- **HIG says**: Information that the user might reference while doing something else should not be modal. +- **Fix**: Convert to NSPanel that floats above Settings, allowing the user to alt-tab to a terminal and copy without dismissing. +- **Effort**: M + +### [P2] No I-beam cursor over the SQL editor when the editor isn't focused + +- **File**: `TablePro/Views/Editor/SQLEditorView.swift` (CodeEditSourceEditor handles cursor when focused) +- **Current**: CodeEditSourceEditor sets the I-beam cursor when the text view is the first responder. Before focus, hovering shows the arrow cursor. +- **HIG says**: Text-editable areas should show the I-beam cursor on hover regardless of focus, like every native text view. +- **Native examples**: TextEdit, Mail compose, Xcode editor. +- **Fix**: Verify CodeEditSourceEditor sets `addCursorRect(_, cursor: .iBeam)` in `resetCursorRects()`. If not, override on the wrapping NSView. +- **Effort**: S to verify + +### [P2] Welcome window allows zero-letter search — no clear-search affordance other than Esc + +- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:212-258` +- **Current**: `NativeSearchField` (custom). No visible "x" clear button mentioned — needs verification. +- **HIG says**: Search fields show a clear button when text is present. +- **Fix**: Verify `NativeSearchField` provides the standard clear button (`(searchField.cell as? NSSearchFieldCell)?.cancelButtonCell`). +- **Effort**: S verify + +### [P2] EditorWindow doesn't customize the proxy icon click behavior for unsaved files + +- **File**: `TablePro/Views/Main/Extensions/MainContentView+Setup.swift:206-208`, `:239-240` +- **Current**: `representedURL` is set, `isDocumentEdited` is set — both correct. The "click and drag the proxy icon to copy" works automatically. But Cmd+Click on the proxy icon (which shows the path popup) is blocked when `titleVisibility = .hidden` (set in TabWindowController.swift:84) because there's no visible title to host the proxy icon dropdown. +- **HIG says**: Proxy icon Cmd+Click revealing the path is a long-standing macOS contract. +- **Native examples**: TextEdit, Pages, Numbers — all support proxy icon Cmd+Click. +- **Fix**: Don't set `titleVisibility = .hidden`. Instead, rely on the toolbar's principal item to display content, but keep the title strip visible so the proxy icon shows. Or implement a custom title bar that includes the proxy icon dropdown. +- **Effort**: M + +### [P2] FeedbackWindowController hides miniaturize and zoom buttons + +- **File**: `TablePro/Views/Feedback/FeedbackWindowController.swift:42-43` +- **Current**: Miniaturize and zoom hidden. +- **HIG says**: Feedback panels can be minimized so the user can collect screenshots and return. +- **Fix**: Show miniaturize. Zoom is fine to disable for a fixed-size panel. +- **Effort**: S + +### [P2] Settings tab labels use single-word verbs ("General", "Editor", "AI") but "Integrations" is a long word — squeezes layout + +- **File**: `TablePro/Views/Settings/SettingsView.swift:52-54` +- **Current**: 9 tabs in a TabView at 720 px width. With "Integrations" + "Plugins" + "Account", labels are tight. +- **HIG says**: Settings tab labels should be short. "Integrations" is fine but could be "MCP" if the only sub-feature is the MCP server (verify scope). +- **Fix**: If "Integrations" only covers MCP today, consider renaming to "MCP" or moving to a sub-page in General. +- **Effort**: S + +### [P2] `JSONViewerWindowController` uses raw `NSWindow` size persistence to UserDefaults — works, but doesn't use `setFrameAutosaveName` + +- **File**: `TablePro/Views/Results/JSONViewerWindowController.swift:36-71`, `:127-138` +- **Current**: Custom `UserDefaults` getter/setter for `NSSize`. Doesn't use `window.setFrameAutosaveName(...)`. +- **HIG says**: macOS provides `setFrameAutosaveName` precisely for window-frame persistence — both size and origin. +- **Fix**: Replace with `window.setFrameAutosaveName("JSONViewer")` and let AppKit handle the disk format. +- **Effort**: S + +--- + +## Summary + +| Severity | Count | +|----------|-------| +| P0 | 7 | +| P1 | 18 | +| P2 | 11 | +| **Total** | **36** | + +### Highest-impact fixes (do first) + +1. **Sheet-from-sheet stacking** (multiple P0/P1) — refactor Export, Import, DatabaseSwitcher, ConnectionForm to use inline state or NavigationStack push instead of nested sheets. This is the single biggest user-visible departure from native pattern. +2. **QuickSwitcher → panel** — standalone panel anchored above the key window, dismissed on focus loss. Add Cmd+1...Cmd+9 quick-jump. +3. **Window menu completeness** — add "Show All Tabs (Cmd+Shift+\\)", "Move Tab to New Window", "Merge All Windows". +4. **Cmd+W behavior** — fix the inverted single-tab close logic so Cmd+W never produces an empty window. +5. **Drag-out from data grid** — drop the same-table-only check, write TSV/HTML so rows can be dropped into Numbers/Excel. +6. **Multi-window-per-connection decision** — pick TablePlus model (single window) or Pages model (multi-window first-class) and enforce. +7. **Destructive button conventions** — set `hasDestructiveAction = true` everywhere, default Cancel for destructive prompts, remove `.defaultAction` Return shortcut from Drop / Truncate / Vacuum. +8. **License activation as a panel** — six call sites today, all stacking sheets. One panel, six triggers. + +### Cross-cutting refactors (do these as a group) + +- Sheet sizing: every sheet that contains a TextEditor or long form needs `minWidth/idealWidth/maxWidth` and `minHeight/maxHeight` so the user can resize. +- Backspace vs forward Delete: audit every `.onKeyPress(.delete)` site and replace with the `\u{7F}\u{08}` characters set already used in WelcomeWindowView. +- Drag-and-drop: every "Open" / "Import" surface should also be a drop target. +- Standard window-button visibility: WelcomeWindow, ConnectionForm, FeedbackWindow all hide miniaturize without good reason — re-enable. diff --git a/docs/refactor/hig-audit/03-chrome-visual.md b/docs/refactor/hig-audit/03-chrome-visual.md new file mode 100644 index 000000000..8cf0bbd77 --- /dev/null +++ b/docs/refactor/hig-audit/03-chrome-visual.md @@ -0,0 +1,388 @@ +# Chrome & Visual Audit (TablePro target) + +**Scope**: toolbar, sidebar, inspector, controls, typography, color, dark mode, accessibility, iconography. +**Source baseline**: `feat/raycast-integration` @ 2026-05-01. +**Method**: read-only static review of `TablePro/Views/**`, `TablePro/Theme/**`, `TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift`, `MainSplitViewController.swift`, `TabWindowController.swift`. Compared against macOS HIG and stock Apple apps (Finder, Mail, Notes, Xcode, System Settings, Music). + +Findings ordered by severity. Single-line summary table at the end. + +--- + +## P0 — Broken native contract + +### [P0] Hard-coded `font(.system(size: …))` everywhere instead of system text styles +- **File**: 80 occurrences across `TablePro/Views/`. Hottest spots: `Views/RightSidebar/EditableFieldView.swift:94,98,109,118`, `Views/RightSidebar/FieldEditors/JsonEditorView.swift:23,34`, `Views/RightSidebar/FieldEditors/BlobHexEditorView.swift:25,36,56,62`, `Views/Connection/WelcomeWindowView.swift:373,408,517`, `Views/Connection/WelcomeConnectionRow.swift:22,37,48`, `Views/Connection/WelcomeLeftPanel.swift:24`, `Views/Settings/AISettingsView.swift:165`, `Views/Settings/LinkedFoldersSection.swift:132`, `Views/Settings/ThemePreviewCard.swift:53`, `Views/Toolbar/ConnectionSwitcherPopover.swift:213,218,229,238`, `Views/Sidebar/FavoriteRowView.swift:15,27`, `Views/Connection/ConnectionSidebarHeader.swift:95,99,121`. +- **Current**: Sizes are pinned to absolute points (often `.system(size: 9)`, `.system(size: 11)`, `.system(size: 32)`, `.system(size: 24, weight: .semibold)`). Many of these clusters are inspector field labels and badges sized at 9 pt — below the macOS minimum readable size, and they do not scale with the user's system text size or accessibility "Larger Text" pref. +- **HIG says**: "Use system fonts and built-in text styles whenever possible" and "Support Dynamic Type." Prefer semantic styles (`.body`, `.callout`, `.caption`, `.caption2`, `.subheadline`, `.headline`, `.title`, `.title3`) so text scales with the user's preferred reading size and matches the rest of the OS. Direct point sizes are reserved for very narrow cases (e.g. art-directed empty-state hero icons). +- **Native examples**: Mail, Notes, Xcode, Finder all render row text at `.body` / `.subheadline` and headers at `.headline`; nothing in stock chrome uses 9 pt text. +- **Fix**: Replace `.font(.system(size: 9))` etc. with semantic styles. Mapping: `9` → `.caption2`, `10–11` → `.caption`, `12–13` → `.subheadline` or `.callout`, `14–16` → `.body`, `17+` → `.title3`/`.title2`. For badge text use `.caption2.weight(.medium)`. Hero icons in empty states are fine as `.system(size: 32)` only when paired with Apple's own `ContentUnavailableView` (which already uses 32 pt). Audit each call: most can drop the explicit size entirely. +- **Effort**: M + +### [P0] Inspector pane labelled "Inspector" but built as a second sidebar with full custom chrome +- **File**: `TablePro/Core/Services/Infrastructure/MainSplitViewController.swift:133-138`, `TablePro/Views/RightSidebar/UnifiedRightPanelView.swift:30-67`, `TablePro/Views/RightSidebar/RightSidebarView.swift`. +- **Current**: The right pane is correctly created with `NSSplitViewItem(inspectorWithViewController:)` (good — that gives the right vibrancy). But the content view starts with a custom `Picker(.segmented)` "Details / AI Chat" tab strip at the top inside the pane (`UnifiedRightPanelView.swift:33-40`). HIG inspectors place mode pickers in the toolbar above the pane, not inside it. The folder name `Views/RightSidebar/` and the type name `RightSidebarView` also encode the wrong mental model — it is an inspector, not a second sidebar. +- **HIG says**: "Inspectors" — "Place an inspector on the trailing side of the window. People can show or hide an inspector to display additional details about an item." Mode switches for an inspector belong on the inspector toolbar accessory, mirroring Xcode (File / History / Quick Help) and Pages (Format / Document). +- **Native examples**: Xcode inspector tab strip lives in the inspector toolbar accessory above the divider. Pages, Numbers, Keynote put the Format/Document switcher in the toolbar above the inspector pane. Finder's Get Info inspector has no inline mode picker. +- **Fix**: Move the Details / AI Chat segmented control out of `UnifiedRightPanelView.body` and into a `NSToolbarItem` in `MainWindowToolbar.swift` aligned over the inspector pane (use `inspectorTrackingSeparator` and place the picker after it). Rename `Views/RightSidebar/` → `Views/Inspector/`, `RightSidebarView` → `InspectorView`, `UnifiedRightPanelView` → `InspectorContentView`, `RightPanelState` → `InspectorState`. Constants like `com.TablePro.rightPanel.isPresented` (`MainSplitViewController.swift:468`) → `com.TablePro.inspector.isPresented` (use a migration read of the old key one time so user state is preserved). +- **Effort**: M + +### [P0] Welcome window hides title bar and clears window background — non-standard chrome +- **File**: `TablePro/Core/Services/Infrastructure/WelcomeWindowFactory.swift:42-48`. +- **Current**: `styleMask = [.titled, .closable, .fullSizeContentView]`, `titleVisibility = .hidden`, `titlebarAppearsTransparent = true`, `isOpaque = false`, `backgroundColor = .clear`, miniaturize and zoom buttons hidden. The result is a frameless, non-zoomable window — Cmd+M and the green button are gone, and the title bar is invisible to drag/double-click-to-zoom. The view itself draws `.background(.background)` (`WelcomeWindowView.swift:34`), so the transparent NSWindow background is doing nothing useful. +- **HIG says**: macOS windows have standard title bars and the standard traffic-light cluster. Hiding the zoom and minimize buttons is reserved for modal panels (sheets, settings, alerts). A welcome window is a regular window — Apple's stock Welcome to Xcode and Welcome to Numbers both have a normal title bar and the full close-min-zoom triplet (zoom is disabled, not hidden, on Welcome to Xcode). +- **Native examples**: Welcome to Xcode, Welcome to Pages, Welcome to Numbers — standard title bar, full traffic-light triplet. +- **Fix**: Remove `titleVisibility = .hidden`, `titlebarAppearsTransparent = true`, `isOpaque = false`, `backgroundColor = .clear`, and the two `standardWindowButton(_:)?.isHidden = true` lines. Keep `.fullSizeContentView` only if the design genuinely extends content under the title bar; otherwise drop it too. Set a real `window.title` (`String(localized: "Welcome to TablePro")` is already there). +- **Effort**: S + +### [P0] Custom `WelcomeButtonStyle` rolls its own bordered button look +- **File**: `TablePro/Views/Connection/WelcomeLeftPanel.swift:91-107`. +- **Current**: `WelcomeButtonStyle` builds a `RoundedRectangle(cornerRadius: 8)` filled with `Color(nsColor: .controlBackgroundColor)` (or `.quaternaryLabelColor` when pressed), 16/12 padding, leading-aligned. This is a re-implementation of `.bordered` / `.borderedProminent` with non-standard pressed states (controls normally darken, not switch background colors). +- **HIG says**: "Buttons" — use system button styles (`.bordered`, `.borderedProminent`, `.borderless`, `.plain`, `.link`). Stock buttons get pressed-state animation, focus ring, accent color, accessibility behavior, and dark-mode treatment for free. +- **Native examples**: Welcome to Xcode "Create New Project / Clone Repository / Open Existing Project" rows use stock `.bordered` `.controlSize(.large)` buttons. System Settings sidebar entries use stock `NSTableView` selection. +- **Fix**: Delete `WelcomeButtonStyle`. Replace `.buttonStyle(WelcomeButtonStyle())` with `.buttonStyle(.bordered)` `.controlSize(.large)`, leading-align with `.frame(maxWidth: .infinity, alignment: .leading)`. If the design needs the asymmetric padding, file it as a P2 polish ticket — most likely the stock control covers it. +- **Effort**: S + +### [P0] `KeyboardHint` builds custom kbd badges instead of using `Text(verbatim:)` with the system pattern +- **File**: `TablePro/Views/Connection/WelcomeLeftPanel.swift:109-128`. +- **Current**: Wraps "⌘N" inside a `RoundedRectangle(cornerRadius: 3)` filled with `.quaternaryLabelColor`. macOS does not draw keyboard shortcut "kbd" pills anywhere in the system — shortcuts in menus are drawn as plain text in the trailing column, and inline shortcuts in tooltips/help are plain text. +- **HIG says**: "Keyboard shortcuts" — show shortcuts with the standard symbol glyphs, in a regular tooltip, status bar, or as the trailing menu accessory. macOS does not use shortcut badges as decorative chrome. +- **Native examples**: Notes "Pinned ⌘P" banner is plain text. Spotlight footer is plain text. Quick Look footer is plain text. +- **Fix**: Drop the rounded rectangle background. Render as `Text("⌘N").font(.system(.caption, design: .monospaced)).foregroundStyle(.tertiary)` followed by the label. Better: replace the entire bottom strip with the standard Welcome window pattern — a footer line of plain affordances ("Show this window when TablePro starts" toggle is what stock Apple welcome windows use). +- **Effort**: S + +### [P0] `TagBadgeView` renders its own capsule pill in the toolbar +- **File**: `TablePro/Views/Toolbar/TagBadgeView.swift:21-35`. +- **Current**: `Text(name).font(.subheadline.weight(.medium)).padding(8/4).background(Capsule().fill(tag.color.color.opacity(0.2)))`. Lives in the principal toolbar item and competes visually with the connection name and DB version. Capsule chrome is not a system pattern in NSToolbar. +- **HIG says**: "Toolbars" — keep items concise and visually consistent. Use the toolbar's title/subtitle, the principal item, or a `.bordered` button. Decorative tinted pills do not appear in stock toolbars. +- **Native examples**: Xcode toolbar shows scheme + run destination as plain text + chevron. Mail toolbar shows mailbox name as title. None paint a colored pill behind status text. +- **Fix**: For `production`-style emphasis, use the existing principal item's window subtitle (`window.subtitle = …`, already wired in `MainSplitViewController.swift:165`) or a small `Image(systemName:)` glyph. If a colored marker is essential, render a 6 pt `Circle().fill(tag.color)` adjacent to the connection name — that mirrors the Finder tag dot and the connection list dot already used in `ConnectionSwitcherPopover`. +- **Effort**: S + +### [P0] Inspector field rows use Capsule pills with hard-coded systemOrange "truncated" badge +- **File**: `TablePro/Views/RightSidebar/EditableFieldView.swift:108-124`. +- **Current**: Type badge `Text(...).font(.system(size: 9, weight: .medium)).background(.quaternary).clipShape(Capsule())` and a "truncated" badge with `.foregroundStyle(Color(nsColor: .systemOrange))` plus 15% systemOrange background. 9 pt is below readable size and not a HIG style. +- **HIG says**: Use `.caption` / `.caption2` semantic styles. For status indicators that need color, use SF Symbols with semantic foregroundStyle (`.tint`, system colors via SwiftUI not NSColor). +- **Native examples**: Xcode build log uses `Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)` next to plain text. Calendar uses small dots, not pills, for tags. +- **Fix**: Replace `font(.system(size: 9, weight: .medium))` with `.font(.caption2.weight(.medium))`. Replace the orange capsule with a leading `Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)` glyph + plain text. Apply the same treatment to the type badge — drop the capsule and render `.foregroundStyle(.tertiary)` text. +- **Effort**: S + +### [P0] `ConnectionSwitcherPopover` rolls its own keyboard-driven list selection +- **File**: `TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift:46-180`. +- **Current**: Manual `selectedIndex: Int`, manual `listRowBackground(RoundedRectangle.fill(Color(nsColor: .selectedContentBackgroundColor)))` for the focused row, manual onKeyPress handlers for ↑/↓/Enter/Esc/Ctrl-J/Ctrl-K. The List is `.listStyle(.sidebar)` but is being rendered inside a popover, where it does not get sidebar vibrancy. +- **HIG says**: Use the system `List(selection:)` binding for keyboard-driven selection. The system manages focus ring, selection background color (light/dark/high-contrast), full keyboard access, and announces row changes to VoiceOver. +- **Native examples**: Spotlight, Raycast (which mirrors Spotlight), Xcode "Open Quickly", System Settings sidebar all use system list selection — none paint their own selection rectangle. +- **Fix**: Switch to `List(selection: $selectedConnectionId)` + `.onKeyPress(.return) { /* connect */ }`. Remove the `listRowBackground` override and `selectedIndex` state. Use `.listStyle(.inset)` (`.sidebar` is wrong inside a popover). Rows become regular `Button { ... } label: { connectionRow(...) }.buttonStyle(.borderless)`. +- **Effort**: M + +### [P0] `ConnectionSwitcherPopover` uses non-semantic `alternateSelectedControlTextColor` for highlighted-row text +- **File**: `TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift:207, 214, 219, 228, 232, 238, 244`. +- **Current**: Every text/icon inside the row branches on `isHighlighted` and substitutes `Color(nsColor: .alternateSelectedControlTextColor)` for foreground. This works for default selection background but breaks under high-contrast and accent color changes (e.g. system accent set to multicolor or a light accent on dark mode). +- **HIG says**: Let SwiftUI/system handle selected-row foregroundStyle. `List` flips foreground to selected-text automatically when a row is selected. +- **Native examples**: Stock List rows show foreground inversion automatically — no app branches on `isHighlighted` to flip text color. +- **Fix**: Once the manual selection is replaced (see previous finding), drop all `isHighlighted ? Color(nsColor: .alternateSelectedControlTextColor) : .primary/.secondary` branches. Plain `.primary` / `.secondary` will invert correctly under selection. +- **Effort**: S (folds into the previous fix) + +### [P0] No `Customize Toolbar…` menu item even though user customization is allowed +- **File**: `TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift:53` (`allowsUserCustomization = true`); `TablePro/TableProApp.swift` View menu has no Customize Toolbar entry. +- **Current**: `allowsUserCustomization = true` on the NSToolbar, so right-click → Customize Toolbar works. But there is no `View > Customize Toolbar…` menu item, which is the stock entry point users look for. +- **HIG says**: "Toolbars" — when a toolbar is customizable, expose a Customize Toolbar… item in the View menu. +- **Native examples**: Mail, Finder, Safari, Xcode all expose `View > Customize Toolbar…`. +- **Fix**: Add `Button("Customize Toolbar…") { NSApp.sendAction(#selector(NSWindow.runToolbarCustomizationPalette(_:)), to: nil, from: nil) }` to the `CommandGroup(after: .sidebar)` block in `TableProApp.swift:460-541`. No keyboard shortcut (Apple does not assign one). +- **Effort**: S + +### [P0] Toolbar shows icon-only by default but no `displayMode` preference; users cannot switch to icon+label +- **File**: `TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift:52`. +- **Current**: `managedToolbar.displayMode = .iconOnly` — fixed. Combined with `autosavesConfiguration = false` (line 54), users cannot persist a different choice. The customization palette still offers Icon Only / Icon and Text / Text Only, but selections are dropped on next launch. +- **HIG says**: Toolbars must persist user customizations. A toolbar that does not autosave is a hostile pattern — repeated re-customization is required after every relaunch. +- **Native examples**: Mail, Finder, Xcode toolbars all autosave display mode and item arrangement. +- **Fix**: `autosavesConfiguration = true`. Drop the explicit `displayMode = .iconOnly` (use the default which respects user pref) or move it behind a one-time "first run" default applied through UserDefaults. Verify that the per-window-instance unique identifier (`NSToolbar(identifier: "com.TablePro.main.toolbar.\(UUID())")`) is intentional — autosave with a per-instance UUID will not persist; if autosave is desired, switch to a stable identifier and address the tab-group sharing issue noted in the comment at lines 47-49 differently (e.g. a single toolbar config shared by all tab windows is what stock apps do). +- **Effort**: M + +### [P0] Sidebar list uses `.listStyle(.sidebar)` but search field lives outside the sidebar's vibrancy region +- **File**: `TablePro/Views/Sidebar/SidebarView.swift:183-241` (List), search field is in `MainSplitViewController.swift:120` via `SidebarContainerViewController`. +- **Current**: SidebarContainerViewController wraps the SwiftUI List inside an NSSplitViewItem(sidebarWithViewController:), which gives correct sidebar vibrancy. But there is no search field in the sidebar itself — connection-list search lives in the Welcome window. In-sidebar search affordance ("Filter tables") is missing entirely; the only filter input on the inspector field list (`RightSidebarView.swift:220-226`) uses `NativeSearchField` (good). +- **HIG says**: "Sidebars" — for table-of-contents sidebars (Mail mailboxes, Xcode navigators, Finder), a search/filter field at the top of the sidebar is the established pattern when the list can grow long. With dozens-to-hundreds of tables in a typical schema, this is needed. +- **Native examples**: Xcode Project navigator has a filter field at the bottom. Mail has search at the top. Finder doesn't use one for sidebar but Xcode is the closer analogue. +- **Fix**: Add a `NativeSearchField` at the top of the sidebar's List inside `SidebarView.tablesContent`, bound to `viewModel.searchText` (already exists in the view model — only the input field is missing in the in-window sidebar). Match the Welcome window's search field control size. +- **Effort**: S + +### [P0] Settings layout uses `TabView` (legacy macOS 12 pattern), not the modern System Settings sidebar style +- **File**: `TablePro/Views/Settings/SettingsView.swift:18-65`. +- **Current**: `TabView` with 9 `tabItem`s (General, Appearance, Editor, Keyboard, AI, Terminal, Integrations, Plugins, Account). Renders the macOS 12-style horizontal tab strip across the top of the Preferences window. Frame fixed at 720×500. +- **HIG says**: macOS 14+ recommends the System Settings pattern: `NavigationSplitView` with sidebar of categories on the left and the active section on the right, `formStyle(.grouped)`, scrollable content. Apple converted every first-party app to this pattern in the macOS Sonoma cycle. +- **Native examples**: System Settings (Sonoma+), Xcode Settings (15+), Notes Settings, Mail Settings, Reminders Settings — all use sidebar + detail. None ship the horizontal tab bar anymore. +- **Fix**: Convert `SettingsView` to `NavigationSplitView { List(selection: $selectedTab) { … } } detail: { switch selectedTab { … } }`. Each tab becomes a sidebar row with `Label("General", systemImage: "gearshape")`. Drop the fixed 720×500 frame (System Settings windows are resizable). Inner section views already use `Form().formStyle(.grouped)` (see `GeneralSettingsView.swift:30, 95`) so they drop in unchanged. +- **Effort**: M + +### [P0] Settings tabs use `.font(.system(size: 32))` empty-state hero icons across multiple panes +- **File**: `Views/Settings/LicenseActivationSheet.swift:22`, `Views/Settings/Sections/MCPAuditLogView.swift:81`, `Views/Settings/Plugins/InstalledPluginsView.swift:297`, `Views/Settings/Plugins/BrowsePluginsView.swift:232`, `Views/Connection/OnboardingContentView.swift:153`, `Views/Connection/ConnectionImportSheet.swift:64,125`, `Views/Connection/ImportFromApp/ImportFromAppSheet.swift:65`, `Views/DatabaseSwitcher/DropDatabaseSheet.swift:26`. +- **Current**: Multiple settings/sheet panes hand-roll the empty state pattern with hard-coded 32 pt SF Symbols + secondary text + tertiary description. +- **HIG says**: macOS 14+ ships `ContentUnavailableView` (and `ContentUnavailableView.search`). It scales correctly with Dynamic Type, supports the standard description+actions layout, and is what stock apps now use for empty states. +- **Native examples**: Photos "No Photos in Library", Mail "No Mailbox Selected", Notes "No Notes" — all `ContentUnavailableView`. `SidebarView.swift:160-174` already uses `ContentUnavailableView` correctly; settings/sheets did not get the same treatment. +- **Fix**: Replace each ad-hoc empty state with `ContentUnavailableView(label, systemImage:, description:)`. Drop the manual VStack + `.font(.system(size: 32))` pattern. +- **Effort**: M + +### [P0] App appearance picker still custom-rolled instead of using `Picker(.segmented)` +- **File**: `TablePro/Views/Settings/AppearanceSettingsView.swift:60-65`. +- **Current**: Uses `.pickerStyle(.segmented)` which is correct, but at the top of the Appearance pane outside any Form. Modern System Settings puts the Appearance toggle in the General pane via `Picker` with the three-image `Light / Dark / Auto` row inside a Form section — the segmented placement at the top of an HSplitView is non-standard. +- **HIG says**: "Pickers" — group settings inside a `Form` so they pick up the standard inset list look in Settings. +- **Native examples**: System Settings → Appearance: three-image picker in a Form section. +- **Fix**: After `SettingsView` is migrated to `NavigationSplitView` (previous P0), put the appearance picker inside a `Section` of a `Form().formStyle(.grouped)` rather than free-floating above an `HSplitView`. +- **Effort**: S + +### [P0] `.help(...)` strings duplicated as `.accessibilityLabel(...)` on icon-only toolbar/sidebar buttons; many icon-only buttons have NO `.accessibilityLabel` +- **File**: `Views/Toolbar/SafeModeBadgeView.swift:27-28` (good — both set), `Views/Toolbar/TagBadgeView.swift:33-34` (good). `Views/Sidebar/RedisKeyTreeView.swift:84,100` (no accessibilityLabel), `Views/Settings/AISettingsView.swift:126` (no accessibilityLabel), `Views/Settings/LinkedFoldersSection.swift:91` (no accessibilityLabel), `Views/Connection/ConnectionColorPicker.swift:23` (no accessibilityLabel), `Views/Connection/ConnectionTagEditor.swift:199` (no accessibilityLabel), `Views/Results/ResultTabBar.swift:49,60` (no accessibilityLabel), `Views/Connection/WelcomeWindowView.swift:186-210` (good — both set). Toolbar buttons in `MainWindowToolbar.swift` rely solely on `.help(...)` — VoiceOver does NOT read `.help`; it reads `.accessibilityLabel`. +- **Current**: 23 `Image(systemName:)` invocations across `Toolbar/`, `Sidebar/`, `RightSidebar/` plus many more in main views. Buttons using `.buttonStyle(.plain)` with bare `Image` labels render as icons-only and need an explicit `.accessibilityLabel`. +- **HIG says**: "Accessibility" — every interactive element must announce itself to VoiceOver. `.help()` is the tooltip text, not the accessibility label. They are separate properties (and on many SF Symbol-only buttons should match). +- **Native examples**: Mail toolbar buttons (Reply, Forward, Move) all have `accessibilityLabel` — verifiable via VoiceOver in Mail. +- **Fix**: Mechanical pass: every `Button { … } label: { Image(systemName: …) }` or every Label-based icon-only `Button` needs `.accessibilityLabel(String(localized: "…"))`. Where help text already exists, the same string usually works. `MainWindowToolbar.swift` toolbar buttons render via `Label("Refresh", systemImage:)` so SwiftUI synthesizes a label from the title — those are OK. The popovers and inline plain buttons are not. +- **Effort**: M + +### [P0] `Image` glyphs lack `.accessibilityHidden(true)` when label text fully describes the row +- **File**: `Views/Sidebar/FavoriteRowView.swift:14-30` is correct (`.accessibilityHidden(true)` on the star, globe, keyword glyphs and `.accessibilityElement(children: .combine)` on the row). Most other rows do NOT do this, e.g. `Views/Connection/WelcomeConnectionRow.swift:20-50` (no `.accessibilityElement(children: .combine)`, status icons not hidden), `Views/Toolbar/ConnectionSwitcherPopover.swift:206-249`, `Views/Toolbar/ConnectionStatusView.swift:74-83`. +- **Current**: VoiceOver navigates through every glyph in a row separately ("image, image, MySQL Local, image, …") instead of reading the row as a single labelled element. +- **HIG says**: "Accessibility" — combine decorative children into one element with a meaningful label. +- **Native examples**: Mail account rows announce as a single sentence ("iCloud, 17 unread messages, account"). +- **Fix**: Wrap row content in `.accessibilityElement(children: .combine)` and mark decorative glyphs `.accessibilityHidden(true)`. Apply systematically across `WelcomeConnectionRow`, the row builder in `ConnectionSwitcherPopover.connectionRow`, `ConnectionStatusView.databaseNameLabel`, `ConnectionSidebarHeader`. +- **Effort**: M + +### [P0] No respect for `accessibilityReduceMotion` on the welcome window's onboarding transition +- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:23-32`. +- **Current**: `withAnimation(.easeInOut(duration: 0.45)) { vm.showOnboarding = false }` plus `.transition(.move(edge: .leading))` / `.transition(.move(edge: .trailing))`. Always animates regardless of system Reduce Motion preference. +- **HIG says**: "Accessibility — Motion" — respect `Reduce Motion` and either replace slide/move with cross-fade or skip the transition entirely. +- **Native examples**: System Settings, Mail message list animations all check Reduce Motion before doing horizontal slides. +- **Fix**: `@Environment(\.accessibilityReduceMotion) private var reduceMotion` then `withAnimation(reduceMotion ? .easeInOut(duration: 0.15) : .easeInOut(duration: 0.45))`. Or use `.transition(reduceMotion ? .opacity : .move(edge: .leading))`. +- **Effort**: S + +--- + +## P1 — Non-idiomatic + +### [P1] `ConnectionStatusView` ignores the user-selected accent color for tag badges and DB type text +- **File**: `TablePro/Views/Toolbar/ConnectionStatusView.swift:42-83`. +- **Current**: Database info uses `ThemeEngine.shared.colors.toolbar.secondaryTextSwiftUI` rather than `.secondary`. The custom theme system overlays user-defined colors over what should be system semantic colors in chrome. Chrome (toolbar text, sidebar text) should track the system, not a per-theme override — themes are meaningful for the editor and data grid only. +- **HIG says**: Toolbars and sidebars use system label colors (`.primary`, `.secondary`, `.tertiary`, `.quaternary`) so they participate in the user's accent color choice and high-contrast pref. +- **Native examples**: Xcode allows editor themes but its toolbar/sidebar always use system colors. +- **Fix**: Replace `ThemeEngine.shared.colors.toolbar.secondaryTextSwiftUI` with `.secondary` and `tertiaryTextSwiftUI` with `.tertiary`. Drop the `ToolbarThemeColors` struct from `ThemeColors.swift:387-407` once unreferenced. Restrict `ThemeEngine` to editor + data grid colors. +- **Effort**: S + +### [P1] `ExecutionIndicatorView` shows "--" placeholder text in toolbar when no query has run +- **File**: `TablePro/Views/Toolbar/ExecutionIndicatorView.swift:57-63`. +- **Current**: Static "--" rendered when `lastDuration == nil`, taking up width permanently. +- **HIG says**: Toolbars should not display empty placeholder content. If there's nothing to show, the item should be hidden or absent. +- **Native examples**: Xcode's progress indicator only appears during a build. +- **Fix**: Render `EmptyView()` (or simply `nil`) when `!isExecuting && lastDuration == nil && lastClickHouseProgress == nil`. The toolbar item width adjusts naturally. +- **Effort**: S + +### [P1] `Form` pickers in settings re-declare `.pickerStyle(.menu)` instead of inheriting Form's default +- **File**: `Views/Settings/AISettingsView.swift:106, 262`. +- **Current**: Explicit `.pickerStyle(.menu)` set on Pickers inside a `Form().formStyle(.grouped)`. `.grouped` Forms already render Pickers as menu-style — the override is a no-op now and could mismatch Apple's Settings updates later. +- **HIG says**: Use Form defaults; let the form pick the right control style for the current style and platform. +- **Fix**: Remove `.pickerStyle(.menu)` calls inside `formStyle(.grouped)` Forms. Only override when the visual is intentionally different (e.g. `.segmented` for an inline 2-3-option binary choice). +- **Effort**: S + +### [P1] `.font(.system(.subheadline, design: .monospaced))` mixed with semantic styles in toolbar +- **File**: `Views/Toolbar/ConnectionStatusView.swift:44`, `Views/Toolbar/ExecutionIndicatorView.swift:27, 31, 45, 51, 59`. +- **Current**: Database type/version + execution time use `.system(.subheadline, design: .monospaced)`. The tabular-figures-via-monospace approach is fine for changing numbers (execution duration) but applying it to "MySQL 8.0.35" in `ConnectionStatusView` looks like a debug HUD, not toolbar text. +- **HIG says**: Toolbars use system font with proportional digits unless live-updating numeric values benefit from monospaced digits. Use `.monospacedDigit()` for numbers, full `.monospaced` only for code-like content. +- **Native examples**: Xcode shows scheme name in proportional font, build progress numbers in monospaced digits via `.monospacedDigit()`. +- **Fix**: `ConnectionStatusView.databaseInfoSection`: drop `.monospaced`, use plain `.subheadline`. `ExecutionIndicatorView`: replace `.system(.subheadline, design: .monospaced).weight(.regular)` with `.subheadline.monospacedDigit()`. +- **Effort**: S + +### [P1] Hard-coded sidebar / inspector min/max widths +- **File**: `TablePro/Core/Services/Infrastructure/MainSplitViewController.swift:122-138`. +- **Current**: `sidebarSplitItem.minimumThickness = 280`, `maximumThickness = 600`. `inspectorSplitItem.minimumThickness = 270`, `maximumThickness = 400`. The 280 sidebar minimum is large — Mail's mailbox sidebar can compress to 150 and Xcode's to 180. With long table names this is fine, but on smaller windows it eats too much detail width. +- **HIG says**: "Sidebars" — minimum widths around 150-200 are typical; 280 is unusually large. +- **Fix**: Drop minimums to 200/220 and let the user resize. Inspector minimum 270 is reasonable for the field editors but reconsider after the field-row design pass. +- **Effort**: S + +### [P1] Tag color badges in `WelcomeConnectionRow` use opacity-tinted rounded rectangle instead of system tag chip +- **File**: `TablePro/Views/Connection/WelcomeConnectionRow.swift:35-44`. +- **Current**: 9 pt text in a `RoundedRectangle(cornerRadius: 4).fill(tag.color.color.opacity(0.15))`. Both the size and the styling violate HIG. +- **HIG says**: For inline metadata in a list row, use semantic foregroundStyle (just colored text) or a 6-8 pt `Circle().fill(tag.color)` like Finder tags. +- **Native examples**: Finder tag dots in list view, Xcode's color labels in the source navigator (small color indicator + plain text label). +- **Fix**: Drop the rectangle background. Render as `HStack(spacing: 4) { Circle().fill(tag.color.color).frame(width: 6, height: 6); Text(tag.name).font(.caption).foregroundStyle(.secondary) }`. +- **Effort**: S + +### [P1] `ConnectionSidebarHeader` button-as-menu uses bespoke chevron and 16 pt icon +- **File**: `TablePro/Views/Connection/ConnectionSidebarHeader.swift:89-129`. +- **Current**: A custom button-shaped row with `Image(systemName: "chevron.down").font(.system(size: 9, weight: .medium))`, `.buttonStyle(.plain)`, no `MenuStyle`. Behaves like `Menu` content but does not render as one. +- **HIG says**: Use `Menu { ... } label: { ... }` for popup menus. macOS draws the standard menu chevron and applies the proper press / hover states. +- **Native examples**: Mail "All Inboxes" header is a regular static label. Xcode scheme picker is `Menu` with default chrome. +- **Fix**: This view is currently unused (sidebar uses search/list, not a connection header) — verify with grep and delete. If kept, replace the custom HStack with `Menu { /* options */ } label: { /* current label */ }.menuStyle(.button).buttonStyle(.borderless)` and drop the manual chevron. +- **Effort**: S + +### [P1] Theme system has parallel "ToolbarThemeColors" and "SidebarThemeColors" that mostly fall through to system colors +- **File**: `TablePro/Theme/ThemeColors.swift:349-407`. +- **Current**: `ToolbarThemeColors` and `SidebarThemeColors` exist but every field is optional and the resolved values fall through to `nil` (i.e. system semantic colors) for the bundled themes. Adds unnecessary surface area for chrome theming, which the app neither documents nor exposes in Settings. +- **HIG says**: Chrome should track the system. Theming the chrome is a power-user feature that needs a high-contrast and dark-mode test matrix. +- **Fix**: Remove `ToolbarThemeColors` and `SidebarThemeColors` from `ThemeColors.swift` and from any `ResolvedThemeColors` plumbing. Audit the editor theme JSON schema (used by the plugin registry — `PluginManager+Registration.swift:259`) and bump the schema version. +- **Effort**: S + +### [P1] Inspector `Section` headers shout in ALL CAPS +- **File**: `TablePro/Views/RightSidebar/RightSidebarView.swift:78, 89, 102, 115`. +- **Current**: `Text("SIZE")`, `Text("STATISTICS")`, `Text("METADATA")`, `Text("TIMESTAMPS")` literal uppercase strings. SwiftUI `Form().formStyle(.grouped)` and `Section` already render headers in the system uppercase styling for grouped Form sections — by passing pre-uppercased strings the result is a double-uppercase title (CSS-style) on macOS Sonoma+. +- **HIG says**: Provide section titles in normal case and let the platform style apply. macOS 14 grouped Forms use small caps with system tracking. +- **Native examples**: System Settings / Network / Wi-Fi / Other Networks shows "Other Networks" in normal case; the platform applies the small-caps treatment. +- **Fix**: `Text("Size")`, `Text("Statistics")`, `Text("Metadata")`, `Text("Timestamps")`. Run through `String(localized:)`. +- **Effort**: S + +### [P1] `ConnectionSwitcherPopover` headers use ALL CAPS too +- **File**: `TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift:78, 111`. +- **Current**: `Text("ACTIVE CONNECTIONS")`, `Text("SAVED CONNECTIONS")`. Same issue as the inspector headers. +- **Fix**: Use natural case ("Active Connections", "Saved Connections"); when the popover is hosted in a List, the system applies the appropriate header treatment. +- **Effort**: S + +### [P1] Onboarding hero icon at 48 pt; standard is 32 pt or via SF Symbol Hierarchical/Multicolor at the system text scale +- **File**: `TablePro/Views/Connection/OnboardingContentView.swift:153`. +- **Current**: `.font(.system(size: 48))` for hero icon. +- **HIG says**: Hero icons in welcome / onboarding views in stock Apple apps sit around 32-40 pt and use `Image(systemName:).symbolRenderingMode(.hierarchical)` for visual depth. +- **Native examples**: Welcome to Xcode hero is roughly 32 pt; System Settings sidebar avatar is 28 pt. +- **Fix**: Drop to 36 pt + `.symbolRenderingMode(.hierarchical)` plus accent color tinting via `.foregroundStyle(.tint)`. +- **Effort**: S + +### [P1] Filter / Columns toggle in status bar uses `.toggleStyle(.button)` with internal HStack content (HIG anti-pattern) +- **File**: `TablePro/Views/Main/Child/MainStatusBarView.swift:165-183`. +- **Current**: A `Toggle(isOn: ...) { HStack { Image; Text("Filters"); Text("(count)") } }.toggleStyle(.button).controlSize(.small)`. The Toggle binding is a fake (the setter ignores the new value and calls `.toggle()` instead) — that's a code smell and a sign the control should be a regular `Button`. +- **HIG says**: `Toggle(.button)` is for a binary state. When the action is "open / close panel", a normal `Button` with `.bordered` and an active-state visual is more honest. +- **Native examples**: Xcode's Filter, Issues, Tests buttons in nav bars are plain buttons with active highlight. +- **Fix**: Replace with `Button { filterStateManager.toggle() } label: { Label("Filters", systemImage: ...) }.buttonStyle(.bordered).tint(filterStateManager.isVisible ? .accentColor : nil).controlSize(.small)`. +- **Effort**: S + +### [P1] Sidebar tag icon hard-codes `.foregroundStyle(.yellow)` and `.foregroundStyle(.pink)` for branding +- **File**: `TablePro/Views/Connection/WelcomeLeftPanel.swift:58` (`.pink`), `TablePro/Views/RightSidebar/EditableFieldView.swift:95` (`.yellow`). +- **Current**: Direct color literals (`.yellow`, `.pink`) bypass the system semantic palette. +- **HIG says**: Use `Color(nsColor: .systemYellow)`, `Color(nsColor: .systemPink)` so colors track the system accent and high-contrast preferences. +- **Native examples**: Sponsor / heart icons in App Store use `.tint` or `Color(nsColor: .systemPink)`. +- **Fix**: `.foregroundStyle(Color(nsColor: .systemYellow))`, `.foregroundStyle(Color(nsColor: .systemPink))`. +- **Effort**: S + +### [P1] `.systemOrange.opacity(0.15)` background on truncated badge — system colors are not designed for opacity backgrounds +- **File**: `TablePro/Views/RightSidebar/EditableFieldView.swift:122`. +- **Current**: `.background(Color(nsColor: .systemOrange).opacity(0.15))` — system colors aren't intended to be tinted at low alpha; the result will not match a real status banner. +- **HIG says**: Use `.regularMaterial` or `.thinMaterial` plus a colored stroke / colored foreground, not opacity-tinted system colors. +- **Fix**: Drop the background entirely (foreground SF Symbol + plain text is enough), or use `.background(.thinMaterial)` and color the icon only. +- **Effort**: S + +### [P1] Tab strip in inspector (Details / AI Chat) uses `Picker(.segmented)` rather than `inspectorMode` toolbar items +- **File**: `TablePro/Views/RightSidebar/UnifiedRightPanelView.swift:33-40`. +- **Current**: Inline segmented picker. (See P0 above for placement; this P1 covers control choice if placement stays.) +- **HIG says**: For 2-3 mode switches inside an inspector, a SF Symbol-based segmented picker is fine, but it should sit in the toolbar accessory above the inspector (Pages/Numbers pattern). If kept inline, prefer `Picker(...).pickerStyle(.segmented).labelsHidden().controlSize(.small)`. +- **Fix**: After moving to the toolbar (P0 fix), keep `.pickerStyle(.segmented)` since stock toolbar accessories also use it. +- **Effort**: folded into P0 + +### [P1] No `accessibilityHint` on icon-only toolbar buttons whose action is non-obvious +- **File**: `MainWindowToolbar.swift:340-350` (Quick Switcher), `:374-388` (Filters), `:393-407` (Preview SQL), `:411-430` (Results), `:448-459` (History). +- **Current**: `.help(...)` is set but no `.accessibilityHint`. For a button labelled "Filters" via SwiftUI Label, VoiceOver speaks "Filters, button" with no indication of what activating it does. +- **HIG says**: "Accessibility" — when a button's effect is not obvious from its label, add a hint that completes the sentence "Activates this control to..." +- **Fix**: Add `.accessibilityHint(String(localized: "Toggles the filter panel"))` etc. to each non-obvious icon button. +- **Effort**: S + +### [P1] `Color.accentColor.opacity(0.4)` shadow on Welcome app icon +- **File**: `TablePro/Views/Connection/WelcomeLeftPanel.swift:20`. +- **Current**: `.shadow(color: Color.accentColor.opacity(0.4), radius: 20, x: 0, y: 0)` — branded glow effect. +- **HIG says**: Welcome window app icons in stock Apple apps do not have colored glow shadows. Drop shadows are reserved for elevation cues, not branding. +- **Native examples**: Welcome to Xcode app icon — no shadow. +- **Fix**: Remove the shadow modifier. If a subtle elevation is desired, use `.shadow(color: .black.opacity(0.15), radius: 6, y: 2)`. +- **Effort**: S + +--- + +## P2 — Polish + +### [P2] `font(.system(size: 9))` on the Pro/badge pill in Welcome screen content rows +- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:373` and elsewhere. +- **Current**: 9 pt is below readable size; tag/badge subscripts. +- **Fix**: Use `.caption2` (11 pt at default scale) and trust system text styles. +- **Effort**: S + +### [P2] `Color(red:)` / `Color(.sRGB:)` literals — none found in `TablePro/Views/` +- **Status**: confirmed clean by grep; finding kept here as a positive note in the audit report. Editor theme stores hex strings in JSON (`ThemeColors.swift:20-29` etc.) which is acceptable since editor colors are user-customizable theme content, not app chrome. Keep `SQLEditorTheme` as the single source of truth for editor colors and ensure no leakage outside the editor. +- **Action**: none. + +### [P2] Tooltip `.help()` strings inline keyboard shortcuts in the tooltip text +- **File**: `MainWindowToolbar.swift:269, 290, 309, 324, 345, 371, 386, 402, 426, 443, 457, 471, 487`. +- **Current**: e.g. `.help(String(localized: "Save Changes (⌘S)"))`. macOS automatically shows shortcuts in tooltips when the matching menu item exists with a keyboardShortcut — duplicating the shortcut in the help text causes a double-display. +- **HIG says**: Let the system render shortcuts; help text should be the action description only. +- **Fix**: Verify whether double-display occurs after the menu integration; if so, drop the inline " (⌘S)" suffixes. Otherwise leave but make sure the shortcut symbol matches the actual keyboard binding (which is user-customizable, so a hard-coded string can drift). +- **Effort**: S + +### [P2] Onboarding/welcome `Spacer().frame(height: 48)` magic spacers +- **File**: `TablePro/Views/Connection/WelcomeLeftPanel.swift:46`. +- **Current**: Hard-coded vertical spacer to compose the layout. Magic numbers without origin in design tokens. +- **Fix**: Replace with VStack alignment + dynamic spacing or extract to a `Spacing` constant if this pattern repeats. +- **Effort**: S + +### [P2] Missing `Image.symbolRenderingMode(.hierarchical)` on inspector / sidebar icons +- **File**: `Views/Sidebar/SidebarView.swift:148`, `Views/RightSidebar/RightSidebarView.swift:148, 164`. +- **Current**: SF Symbols render flat — no hierarchical depth. +- **HIG says**: SF Symbols offer monochrome, hierarchical, palette, and multicolor rendering modes. Hierarchical adds subtle visual hierarchy that stock apps consistently apply to status icons. +- **Fix**: `Image(systemName: ...).symbolRenderingMode(.hierarchical)` on status / non-glyph-button icons. +- **Effort**: S + +### [P2] `ContentUnavailableView` not used for inspector empty state — uses ad-hoc VStack +- **File**: `TablePro/Views/RightSidebar/RightSidebarView.swift:54-61`. +- **Current**: Already uses `ContentUnavailableView` (good!) but the icon "sidebar.right" isn't a great match — for an inspector that hosts row details / table info, `tablecells.badge.ellipsis` or `info.circle` is more semantic. +- **Fix**: Replace `systemImage: "sidebar.right"` with a more representative icon. +- **Effort**: S + +### [P2] Settings tab order +- **File**: `TablePro/Views/Settings/SettingsView.swift:18-65`. +- **Current**: Order is General, Appearance, Editor, Keyboard, AI, Terminal, Integrations, Plugins, Account. Modern System Settings groups (1) account/identity at top, (2) appearance/general, (3) feature panes, (4) plugins/extensions. +- **Fix**: After the NavigationSplitView migration, reorder to General, Account, Appearance, Editor, Keyboard, Terminal, AI, Integrations, Plugins. (Account near top is the macOS norm.) +- **Effort**: S + +--- + +## Summary + +| ID | Severity | Area | Title | Effort | +|----|----------|------|-------|--------| +| CV-01 | P0 | Typography | Hard-coded font sizes; switch to semantic styles | M | +| CV-02 | P0 | Inspector | Mode picker inline; rename "RightSidebar" → "Inspector" | M | +| CV-03 | P0 | Welcome window | Restore standard title bar + traffic-light triplet | S | +| CV-04 | P0 | Controls | Drop `WelcomeButtonStyle`, use `.bordered` | S | +| CV-05 | P0 | Welcome | Drop `KeyboardHint` rounded-rect badges | S | +| CV-06 | P0 | Toolbar | Drop `TagBadgeView` capsule chrome | S | +| CV-07 | P0 | Inspector | Drop capsule pills, fix systemOrange badge | S | +| CV-08 | P0 | Popover | Use system List selection in `ConnectionSwitcherPopover` | M | +| CV-09 | P0 | Popover | Drop `alternateSelectedControlTextColor` color flips | S | +| CV-10 | P0 | Toolbar | Add `View > Customize Toolbar…` menu item | S | +| CV-11 | P0 | Toolbar | Enable `autosavesConfiguration`; fix per-instance UUID | M | +| CV-12 | P0 | Sidebar | Add filter search field above tables list | S | +| CV-13 | P0 | Settings | Migrate `TabView` Settings to `NavigationSplitView` | M | +| CV-14 | P0 | Empty states | Replace 32 pt hero VStacks with `ContentUnavailableView` | M | +| CV-15 | P0 | Settings | Move appearance picker into a Form section | S | +| CV-16 | P0 | Accessibility | Add missing `.accessibilityLabel` to icon-only buttons | M | +| CV-17 | P0 | Accessibility | Combine row children + hide decorative glyphs | M | +| CV-18 | P0 | Accessibility | Respect `accessibilityReduceMotion` in welcome transition | S | +| CV-19 | P1 | Color | Drop `ThemeEngine.toolbar` colors; chrome tracks system | S | +| CV-20 | P1 | Toolbar | Hide ExecutionIndicator placeholder when idle | S | +| CV-21 | P1 | Settings | Drop redundant `.pickerStyle(.menu)` inside `.grouped` Form | S | +| CV-22 | P1 | Typography | Use `.monospacedDigit()`, not `.monospaced`, for numbers | S | +| CV-23 | P1 | Sidebar | Reduce sidebar minimum width 280 → 200 | S | +| CV-24 | P1 | Welcome | Tag chip → tag dot in `WelcomeConnectionRow` | S | +| CV-25 | P1 | Sidebar | Replace `ConnectionSidebarHeader` custom button-as-menu with `Menu` (or delete if unused) | S | +| CV-26 | P1 | Theme | Remove `ToolbarThemeColors` / `SidebarThemeColors` from theme schema | S | +| CV-27 | P1 | Inspector | Section titles in normal case, not ALL CAPS | S | +| CV-28 | P1 | Popover | Section titles in normal case in `ConnectionSwitcherPopover` | S | +| CV-29 | P1 | Onboarding | Reduce hero icon from 48 pt to 36 pt + hierarchical | S | +| CV-30 | P1 | Status bar | Replace fake-Toggle filter button with real `Button` | S | +| CV-31 | P1 | Color | Replace `.yellow` / `.pink` literals with `Color(nsColor: .systemYellow/Pink)` | S | +| CV-32 | P1 | Inspector | Drop opacity-tinted systemOrange badge background | S | +| CV-33 | P1 | Accessibility | Add `accessibilityHint` to non-obvious icon buttons | S | +| CV-34 | P1 | Welcome | Drop accent-colored shadow on app icon | S | +| CV-35 | P2 | Typography | Replace 9 pt badge text with `.caption2` | S | +| CV-36 | P2 | Tooltip | Drop inline shortcut suffix from `.help()` strings | S | +| CV-37 | P2 | Welcome | Replace magic `Spacer().frame(height: 48)` | S | +| CV-38 | P2 | SF Symbols | Apply `.symbolRenderingMode(.hierarchical)` to status icons | S | +| CV-39 | P2 | Inspector | Replace `sidebar.right` empty-state icon with semantic glyph | S | +| CV-40 | P2 | Settings | Reorder tabs (Account near top) | S | + +**Counts**: P0 = 18, P1 = 16, P2 = 6, total 40 actionable items. + +The two largest themes are: +1. **Custom chrome where standard exists** — capsules, pills, custom buttons, custom selection backgrounds, custom kbd badges. The fix is mechanical removal in favor of `.bordered`, `.borderless`, semantic colors, and system-managed selection. +2. **Hard-coded typography** — 80 sites of `.font(.system(size: …))`, none of which scale with Dynamic Type. The fix is a pass replacing each with the closest semantic style. + +Both unblock the deeper inspector / settings / welcome HIG migrations. diff --git a/docs/refactor/hig-audit/04-system-document.md b/docs/refactor/hig-audit/04-system-document.md new file mode 100644 index 000000000..87c7a3c9b --- /dev/null +++ b/docs/refactor/hig-audit/04-system-document.md @@ -0,0 +1,238 @@ +# 04 — System & Document Model Audit + +**Agent**: system-document-auditor +**Scope**: `TablePro/` target only. Document model, file associations, Open/Save panels, Undo/Redo, Find/Replace, Settings, About, Services, Notifications, Sparkle, Dock, sandbox/entitlements, Quick Look, localization. +**Date**: 2026-05-01 +**Conclusion**: TablePro hand-rolls a partial document model on top of `QueryTab` and `NSWindow.representedURL`/`isDocumentEdited`. Core Apple infrastructure for documents is missing: no `NSDocument`/`FileDocument`, no Open Recent menu, no auto-save, no Versions, no Revert/Duplicate/Rename/Move To, no Quick Look, no Services. The bridge that does exist works, but every feature Apple gives you for free has to be reimplemented or ignored. + +--- + +## P0 — Broken native contracts + +### [P0] No document model — SQL files are not first-class documents +- **File**: `TablePro/TableProApp.swift:628`, `TablePro/Models/Query/QueryTabState.swift:257`, `TablePro/Core/Services/Infrastructure/SQLFileService.swift:1` +- **Current**: SQL files are loaded into `TabQueryContent.query` (a plain `String`) tracked by `sourceFileURL` + `savedFileContent`. The dirty state is computed by string comparison at `QueryTabState.swift:266`. There is no `NSDocument`, no `FileDocument`/`ReferenceFileDocument`, no `DocumentGroup`. `TableProApp.body` declares only a `Settings { }` scene; main windows are AppKit-imperative. Search confirms zero usages of `NSDocument`/`FileDocument`/`DocumentGroup` (only one comment mention at `AlertHelper.swift:139`). +- **HIG says**: "Documents — Use the document architecture so people can save, open, rename, move, duplicate, revert, and version their files using the same controls they use in every other macOS app." Apple's File menu items (Save, Save As, Duplicate, Rename, Move To, Revert To, Save All, Open Recent) come for free with `NSDocumentController` and `NSDocument`. Hand-rolled document tracking is the canonical reason apps fail HIG review. +- **Native examples**: TextEdit (`NSDocument`), Xcode (`SourceCodeDocument`), Pages, Numbers, Keynote, BBEdit, Nova, Tot. +- **Fix**: Introduce a `SQLDocument: ReferenceFileDocument` (reference-typed because `QueryTab` is mutable and shared with the data grid). Migrate the SQL-file lifecycle out of `MainContentCommandActions.openSQLFile()` / `saveFileAs()` / `saveFileToSourceURL()` / the `.openSQLFiles` Notification, and let SwiftUI's `DocumentGroup` (or a parallel AppKit `NSDocumentController` registration) own Open / Save / Save As / Revert / Duplicate / Rename / Move To / Open Recent for `.sql` files. Connection-bound query tabs that aren't backed by a file remain `QueryTab` only — the document layer wraps the file-bound tabs. +- **Effort**: L + +### [P0] Open Recent menu is missing +- **File**: `TablePro/TableProApp.swift:197-293` (File menu uses `CommandGroup(replacing: .newItem)` and `CommandGroup(after: .newItem)`), `TablePro/Core/Services/Infrastructure/TabRouter.swift:322` (`openSQLFile` never registers recents). +- **Current**: No "Open Recent ▶" submenu anywhere in the File menu. `noteNewRecentDocumentURL` is never called in the entire codebase. SQL files opened from the open panel, drag-drop, Finder Open With, and `tablepro://` URLs never appear in Open Recent. +- **HIG says**: "Open Recent — Most apps that work with documents should provide an Open Recent menu" (HIG → File management). The menu is auto-populated when the app uses `NSDocumentController`, or by calling `NSDocumentController.shared.noteNewRecentDocumentURL(_:)` on every successful open. macOS adds the standard "Clear Menu" item automatically. +- **Native examples**: TextEdit, Xcode, Preview, Pages, BBEdit. Every native document-based app on macOS. +- **Fix**: After moving to `NSDocument`/`ReferenceFileDocument` (P0 above) this comes for free. Until then, call `NSDocumentController.shared.noteNewRecentDocumentURL(url)` from `TabRouter.openSQLFile(_:)`, the SQL `NSOpenPanel` callback in `MainContentCommandActions.openSQLFile()`, and `application(_:open:)` in `AppDelegate.swift:17`. Add `CommandGroup(after: .newItem) { OpenRecentMenu() }` (custom subview that reads `NSDocumentController.shared.recentDocumentURLs`) so the menu actually renders in the SwiftUI command tree. +- **Effort**: M (S if folded into the NSDocument migration). + +### [P0] No auto-save, no Versions, no "Edited" Time Machine integration +- **File**: `TablePro/Views/Main/MainContentCommandActions.swift:420-436` (`saveFileToSourceURL`), `TablePro/Models/Query/QueryTabState.swift:257`. +- **Current**: SQL files are saved only when the user explicitly invokes Save (`Cmd+S`) or accepts the unsaved-changes alert on tab close. `applicationShouldTerminate` (`AppDelegate.swift:99`) shows a custom warning and discards on quit. There is no auto-save timer, no `NSDocument.autosavesInPlace`, no `NSFileVersion` snapshots, no Time Machine "Browse All Versions…" support. +- **HIG says**: "Documents — Modern apps should auto-save documents and integrate with Versions so people can recover earlier states." `NSDocument.autosavesInPlace` returning `true` enables auto-save, the dirty-dot in the close button, the proxy icon's "Locked / Edited / Last opened" tooltip, and the File ▸ Revert To ▸ Browse All Versions… menu. +- **Native examples**: TextEdit, Pages, Notes, Xcode (project files), BBEdit, Tot. Apple's HIG explicitly contrasts "modern" apps (auto-save) against "legacy" apps with manual save dialogs. +- **Fix**: Resolve via the `ReferenceFileDocument` migration (P0 first finding). For SQL files, opt into `NSDocument.autosavesInPlace` (free with `ReferenceFileDocument`) and remove the custom unsaved-changes alert in `closeTab()` / `applicationShouldTerminate`. Keep the custom alert only for non-document state (data-grid edits, structure edits) which don't have a backing file. +- **Effort**: L (folded into NSDocument migration). + +### [P0] File menu lacks Revert / Duplicate / Rename / Move To +- **File**: `TablePro/TableProApp.swift:204-293`. +- **Current**: The custom File menu provides only New Tab, New View, Open Database, Open File, Save Changes, Save As, Close Tab, Export/Import. Missing: Duplicate (`Cmd+Shift+S` in the Apple-standard layout), Rename…, Move To…, Revert To ▸ Last Saved / Browse All Versions…, Save All. These items are auto-inserted by `NSDocument` and required by HIG for document-based apps. +- **HIG says**: "File menu" lists File ▸ Save, Save As, Save All, Duplicate, Rename, Move To, Revert To as the standard set. Removing them silently from a document-based app breaks user muscle memory across the OS. +- **Native examples**: TextEdit, Pages, Numbers, Xcode. Even simple document apps like Tot include Duplicate / Rename / Move To. +- **Fix**: Once SQL files become `NSDocument`-backed, these items appear automatically. If we choose to keep the hand-rolled model, manually add these items to `CommandGroup(after: .newItem)` and wire them to `NSDocumentController` selectors (`saveDocument:`, `duplicateDocument:`, `renameDocument:`, `moveDocument:`, `revertDocumentToSaved:`). +- **Effort**: S if going via NSDocument; M otherwise. + +### [P0] Cmd+G / Cmd+Shift+G (find next/previous) not wired +- **File**: `TablePro/TableProApp.swift:430-435`, `TablePro/Views/Editor/EditorEventRouter.swift:93`. +- **Current**: `Cmd+F` shows the find panel via `EditorEventRouter.shared.showFindPanelForKeyWindow()`. There is no menu item or shortcut for Find Next (`Cmd+G`) or Find Previous (`Cmd+Shift+G`). Search confirms zero usages of `performTextFinderAction`, `findNext`, or `findPrevious` selectors in the project. +- **HIG says**: The Find submenu in the Edit menu must include Find… (`Cmd+F`), Find Next (`Cmd+G`), Find Previous (`Cmd+Shift+G`), Use Selection for Find (`Cmd+E`), and Jump to Selection (`Cmd+J`). All five are auto-installed when the app implements the standard `NSTextFinder` responder chain. CodeEditTextView's `TextView` already implements these. +- **Native examples**: TextEdit, Xcode, Safari, Mail, Pages, BBEdit. Every text editor on macOS. +- **Fix**: Replace the custom `Cmd+F` button with a SwiftUI `CommandGroup(replacing: .textEditing)` that exposes the standard Find submenu, or add explicit Buttons for findNext / findPrevious / useSelectionForFind / jumpToSelection that send `#selector(NSTextFinder.performAction(_:))` (or the responder-chain wrappers). CodeEdit's TextView responds to `performTextFinderAction(_:)` via `NSTextFinderClient`; route through the responder chain. +- **Effort**: S + +### [P0] Edit menu lacks Find Next / Find Previous / Use Selection for Find / Jump to Selection +- **File**: `TablePro/TableProApp.swift:427-457`. +- **Current**: The "Find" Button at line 430 is the only item in the Find area, replacing nothing — there's no submenu structure. SwiftUI's default Find submenu (which would have all five items) is suppressed because the app declares `CommandGroup(after: .pasteboard)` with a single `Button("Find...")`. +- **HIG says**: Same as above; standard Find submenu must include all five items. +- **Native examples**: TextEdit, Xcode, BBEdit. +- **Fix**: Restructure as `CommandGroup(replacing: .textEditing)` + `CommandMenu("Find") { ... }` containing all five canonical entries, or stop suppressing the default Find submenu and only override the items we need. +- **Effort**: S (combine with the previous item). + +### [P0] AppDelegate's custom unsaved-changes alert duplicates `NSDocument` semantics, fights auto-save +- **File**: `TablePro/AppDelegate.swift:99-118`. +- **Current**: `applicationShouldTerminate` walks `MainContentCoordinator.hasAnyUnsavedChanges()` and shows a single warning sheet ("You have unsaved changes / Quitting will discard these changes") with destructive "Quit Anyway". +- **HIG says**: For document apps, Apple owns the quit-with-unsaved-documents flow (`NSDocumentController.reviewUnsavedDocuments(...)`), iterating each unsaved document with the standard "Save / Cancel / Don't Save" dialog. Auto-save apps don't need this alert at all — they auto-save on quit. +- **Native examples**: TextEdit, Pages, Numbers, Xcode all let `NSDocumentController` handle quit review. +- **Fix**: After NSDocument migration, delete this alert and let `NSDocumentController` handle quit review. Keep a custom alert only for non-document state (uncommitted data-grid / structure edits) and present it through `NSApplication.reply(toApplicationShouldTerminate:)`. +- **Effort**: S after document migration; otherwise leave but add per-document iteration. + +--- + +## P1 — Non-idiomatic + +### [P1] Settings window uses old `TabView` toolbar style (pre-macOS 13) +- **File**: `TablePro/Views/Settings/SettingsView.swift:17-66`. +- **Current**: `SettingsView` is a `TabView { ... .tabItem { Label("...", systemImage: "...") } ... }` with a fixed `.frame(width: 720, height: 500)` and 9 tabs (General, Appearance, Editor, Keyboard, AI, Terminal, Integrations, Plugins, Account). This renders as the legacy toolbar-tabbed Preferences window. +- **HIG says**: macOS Sonoma (14) and later use the Settings sidebar/detail layout (`NavigationSplitView` style), matching the system Settings app. SwiftUI's `Settings { }` scene supports both, but `TabView` with `.tabItem` modifiers locks you to the old style. With nine sections, the toolbar gets cramped — the sidebar style is what System Settings, Xcode 15, and Mail now use. +- **Native examples**: Xcode 15 Settings, System Settings, Mail Settings, Notes Settings, Things 3. +- **Fix**: Refactor `SettingsView` into a `NavigationSplitView` with a fixed sidebar listing the nine sections and a detail pane swapping in each section view. Keep the `@AppStorage("selectedSettingsTab")` binding so deep-links from `LaunchIntentRouter` and `AppDelegate.handlePluginsRejected` still navigate to the right pane. +- **Effort**: M + +### [P1] `applicationShouldTerminateAfterLastWindowClosed` not implemented; closing all windows shows Welcome instead of quitting +- **File**: `TablePro/AppDelegate.swift:172-184`. +- **Current**: `windowWillClose(_:)` posts `.mainWindowWillClose` and re-opens the Welcome window when the last main window closes. Apple's standard pattern for menu-bar/utility apps that want to keep running after closing all windows is to override `applicationShouldTerminateAfterLastWindowClosed`. For a document app, the convention is the opposite: closing the last window does **not** quit (the app stays in the dock to handle drag-drop / Open Recent). +- **HIG says**: "Document-based apps should keep running after the last document closes; users open another via File ▸ Open Recent or by dragging onto the Dock icon." TablePro behaves correctly (stays running) but achieves it through a custom Welcome-window springback, which is its own non-native pattern (see `02-windows-interactions.md` for details). +- **Native examples**: TextEdit, Pages — closing the last window leaves the app running; the Dock icon stays bouncy and `applicationShouldHandleReopen` shows the Open dialog. +- **Fix**: After document migration, remove the Welcome-springback. Implement `applicationShouldTerminateAfterLastWindowClosed → false` and rely on `applicationShouldHandleReopen` (already present at `AppDelegate.swift:27`) to surface the Welcome window when the user clicks the Dock icon. +- **Effort**: S + +### [P1] Hand-rolled Save Changes alert duplicates `NSDocument`'s built-in behavior +- **File**: `TablePro/Core/Utilities/UI/AlertHelper.swift:130-163`, `TablePro/Views/Main/MainContentCommandActions.swift:327-350`. +- **Current**: `confirmSaveChanges` builds an `NSAlert` with "Save / Cancel / Don't Save" buttons and a custom `Cmd+D` for "Don't Save". The button order and shortcut are correct (commit at `2f5b4f8e` style — comment at line 139 even references the convention). But the alert is built manually for each call site rather than letting `NSDocument.canClose(withDelegate:...)` handle it. +- **HIG says**: `NSDocument` provides this dialog, with the correct localization in 30+ languages, the correct destructive-action styling, and the correct return mapping. Apps that re-implement this alert get subtly different behavior from system apps (and have to maintain translations). +- **Native examples**: TextEdit, Pages, Xcode. +- **Fix**: Keep `AlertHelper.confirmSaveChanges` only for non-document state (data-grid / structure edits). Route file-dirty checks through `NSDocument.canClose(withDelegate:...)`. +- **Effort**: S after document migration. + +### [P1] No iCloud Drive / ubiquitous documents, despite iCloud entitlement +- **File**: `TablePro/TablePro.entitlements:7-15`. +- **Current**: The entitlements file enables `com.apple.developer.icloud-container-identifiers` and CloudKit, but no `NSUbiquitousContainers` Info.plist key is set and no document presenter / file coordinator code exists. iCloud is wired up only for connection sync. +- **HIG says**: If you ship a document type and use iCloud, also expose iCloud Drive so users can store SQL files in iCloud. Either remove the iCloud entitlement scope or wire it to documents. +- **Native examples**: TextEdit (iCloud Drive container), Pages, Numbers. +- **Fix**: After NSDocument migration, add `NSUbiquitousContainers` to Info.plist with a `NSUbiquitousContainerIsDocumentScopePublic = YES` entry so SQL files appear in `~/Library/Mobile Documents/com~TablePro~iCloud/Documents/`. Or scope the iCloud entitlement strictly to the connection-sync container. +- **Effort**: S + +### [P1] About panel hand-builds links into Credits — should ship `Credits.rtf` +- **File**: `TablePro/TableProApp.swift:146-174`, `TablePro/Resources/`. +- **Current**: The "About TablePro" Button programmatically constructs an `NSAttributedString` with four links (Website / GitHub / Documentation / Sponsor) and passes it via `NSApplication.shared.orderFrontStandardAboutPanel(options: [.credits: ...])`. There is no `Credits.rtf` (or `.html`) in `Resources/` (verified with `find`). +- **HIG says**: The standard about panel reads `Credits.rtf` / `Credits.html` automatically when present. Ship the file in the bundle and let macOS render it. This also localizes (`Credits.rtf` per .lproj) without requiring `String(localized:)` plumbing for link labels. +- **Native examples**: Almost every native macOS app ships `Credits.rtf` (Xcode, Pages, Bartender, Tot). +- **Fix**: Move the four links into a `Credits.rtf` (or per-locale variants in `Resources/en.lproj/Credits.rtf`) and remove the inline construction. Keep just `NSApplication.shared.orderFrontStandardAboutPanel(options: [:])`. +- **Effort**: S + +### [P1] `panel.message` strings hardcoded in English (localization regression) +- **File**: `TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift:140`, `TablePro/Views/Import/ImportDialog.swift:301`. +- **Current**: Two `NSOpenPanel` instances use hardcoded `panel.message = "Select SQL file to import"` and `"Select file to import"`. CLAUDE.md mandates `String(localized:)` for new user-facing strings; the rest of the codebase complies (`SQLFileService.swift:39,52`, `Plugins/InstalledPluginsView.swift:377`, `ERDiagramView.swift:286`). +- **HIG says**: All system-presented file panel messages should localize alongside the rest of the UI. +- **Fix**: Wrap both with `String(localized:)`. +- **Effort**: S + +### [P1] No `UNUserNotificationCenter` usage — no notification for long queries, sync events, or update available +- **File**: codebase-wide (zero matches for `UNUserNotificationCenter`/`UNNotificationRequest`). +- **Current**: TablePro never delivers a user notification. Long queries finish silently (only the in-app status bar updates). Sync conflicts surface only inside the Settings panel. Sparkle uses its own in-app dialog, which is fine. +- **HIG says**: "Notifications — Use notifications for events the user might want to know about when your app isn't in front." A query that takes 30 seconds (default `confirm_destructive_operation` warns at 5 s in MCP) absolutely qualifies. The user might switch to another app while waiting. +- **Native examples**: Xcode (build complete), Mail (new messages), Calendar (alarms), Activity Monitor (high CPU), Time Machine (backup complete). +- **Fix**: Add `UNUserNotificationCenter.current().requestAuthorization(...)` lazily on the first long query (>10 s elapsed). When a query finishes while the app is not the frontmost, post a `UNMutableNotificationContent` with title="Query finished" and body=" rows in ". Same for sync conflicts. Hide behind a setting in General → Notifications. Don't request authorization at launch. +- **Effort**: M + +### [P1] No Quick Look preview for `.sql` files +- **File**: `TablePro/Info.plist:11-105` declares `.sql` documents but there is no Quick Look generator (no `QuickLookThumbnailing` extension, no `QLPreviewPanel` integration). +- **Current**: Selecting a `.sql` file in Finder and pressing space shows the system-default plain-text preview (because `com.tablepro.sql` conforms to `public.plain-text`). It works because of the conformance, but it's the unstyled, monospace plain-text Quick Look — no syntax highlighting, no per-statement count, no theme. +- **HIG says**: Apps that own a document type should ship a Quick Look extension (or thumbnail extension) that renders previews matching the in-app appearance. +- **Native examples**: Xcode (`.swift` previews with syntax highlighting), Pages, Pixelmator Pro. +- **Fix**: Add a `Quick Look Preview` app extension target that renders the SQL with the same `SQLEditorTheme` palette via `CodeEditSourceEditor` in read-only mode. Lower priority than the document model itself. +- **Effort**: M + +### [P1] App is unsandboxed — blocks Mac App Store distribution and trips off-store warnings +- **File**: `TablePro/TablePro.entitlements:19-22`, `TablePro.xcodeproj/project.pbxproj:2272,2350` (`ENABLE_APP_SANDBOX = NO`). +- **Current**: `com.apple.security.app-sandbox = false`. `com.apple.security.cs.disable-library-validation = true` (needed for plugin loading from outside the bundle). Hardened Runtime is on (`ENABLE_HARDENED_RUNTIME = YES`). +- **HIG says**: "Distributing your app — Mac App Store apps must be sandboxed." This is a roadmap concern, not a HIG bug per se, but it's the single biggest gap between TablePro and TablePlus/Postico/Sequel Ace (all sandboxed-when-needed). The root cause for unsandboxed-ness is plugin loading — `.tableplugin` bundles outside the app bundle can't be loaded under sandbox. +- **Native examples**: TablePlus (sandboxed App Store build, unsandboxed direct build), Postico (sandboxed), Sequel Ace (sandboxed). +- **Fix**: Out of scope for this audit, but flagged: a sandbox-eligible build would need plugins to be signed Apple-distributed app extensions (or accept the no-third-party-plugins limitation in the App Store variant). The MCP server / SSH tunnel / network DB drivers all need sandbox-permitted entitlements (`com.apple.security.network.client`, `com.apple.security.files.user-selected.read-write`, etc.). The disable-library-validation entitlement is incompatible with the App Store. Track this as a separate "App Store readiness" project, not part of HIG refactor. +- **Effort**: L (parallel project) + +### [P1] No Services menu integration +- **File**: codebase-wide (zero matches for `registerServicesMenuSendTypes`, `NSServicesMenu`, `writeSelection(to:`). +- **Current**: TablePro neither registers any service ("Run as Query in TablePro") nor accepts services from other apps (e.g., "Format selected SQL"). +- **HIG says**: "Services menu — Provide services for the parts of your app that produce or consume data others might want." Optional, not required. Most database clients ignore services. +- **Native examples**: BBEdit ("New BBEdit Document Containing Selection"), TextEdit, Mail, Safari. +- **Fix**: Optional — provide an "Open in TablePro" service that takes selected SQL text, opens a new query tab on the active connection. Add `NSServices` keys to Info.plist and call `NSApp.servicesProvider = ServicesProvider()` from `applicationDidFinishLaunching`. Low priority compared to document model gaps. +- **Effort**: S (optional) + +### [P1] Custom Cmd+W close-tab logic mixed with `applicationShouldTerminate` quit-review duplicates work +- **File**: `TablePro/Views/Main/MainContentCommandActions.swift:327-350`, `TablePro/AppDelegate.swift:99-118`. +- **Current**: Two separate code paths handle "save before going away" — `closeTab()` shows `confirmSaveChanges`, `applicationShouldTerminate` shows a different alert. They use different button orders, different copy, different shortcut bindings. +- **HIG says**: A document-based app delegates both flows to `NSDocumentController.reviewUnsavedDocuments(...)` for consistency. +- **Native examples**: TextEdit, Pages. +- **Fix**: Consolidate via `NSDocument.canClose(withDelegate:...)` (post-migration). Until then, share a single helper that produces the same alert copy/shortcuts. +- **Effort**: S after document migration. + +--- + +## P2 — Polish + +### [P2] `MainContentCommandActions.openSQLFile()` round-trips through NotificationCenter +- **File**: `TablePro/Views/Main/MainContentCommandActions.swift:558-563`, `TablePro/Views/Main/MainContentCommandActions.swift:786-795`. +- **Current**: `openSQLFile()` shows the panel, then posts `.openSQLFiles` with the URLs. The same actions object subscribes via `observeKeyWindowOnly(.openSQLFiles)` and routes to `TabRouter.shared.route(.openSQLFile(url))`. The Notification round-trip is unnecessary — just call the router directly. +- **HIG says**: N/A; this is internal architecture noise. +- **Fix**: Remove `openSQLFiles` Notification, call `TabRouter` directly from `openSQLFile()`. Notification is also posted from `AppDelegate.application(_:open:)` indirectly through `AppLaunchCoordinator` — pick one path. +- **Effort**: S + +### [P2] `application(_:open:)` doesn't note recent documents — even Drag-and-Drop / Open With opens are silent +- **File**: `TablePro/AppDelegate.swift:17-19`. +- **Current**: `application(_:open:)` forwards to `AppLaunchCoordinator.shared.handleOpenURLs(urls)` and never calls `NSDocumentController.shared.noteNewRecentDocumentURL`. Same gap on every other entry point. Already covered by P0 above; flagged here to ensure the fix touches every entry point. +- **Fix**: Single audit pass to ensure every entry point (open panel, Open With from Finder, drag onto Dock, drag onto window, `tablepro://` URL with file path, recent reopen) feeds through the same `noteNewRecentDocumentURL` hook. +- **Effort**: S + +### [P2] `panel.title` is set on `NSOpenPanel` (deprecated API for sheets) +- **File**: `TablePro/Views/Settings/Plugins/InstalledPluginsView.swift:377`, `TablePro/Views/ERDiagram/ERDiagramView.swift:285`. +- **Current**: `panel.title = ...` is set on the open/save panel. As of macOS 11, `NSSavePanel.title` shows only when the panel is a window, not a sheet. For sheets (which is how these are presented via `beginSheetModal(for:)`), the title is ignored. +- **HIG says**: Use `panel.message` for the descriptive text on sheets; `panel.prompt` to override the default action button label ("Open" / "Save"). +- **Fix**: Replace `panel.title` with `panel.message` (or remove if `panel.message` is already set). Trivial cleanup. +- **Effort**: S + +### [P2] Dock right-click menu omits "New Tab" / "Open Recent" / per-window context +- **File**: `TablePro/AppDelegate.swift:194-236`. +- **Current**: `applicationDockMenu(_:)` returns "Show Welcome Window" + per-connection "Open Connection" submenu. Missing: "Open Recent ▶" (would auto-populate from `NSDocumentController.shared.recentDocumentURLs`), "New Query Tab" (only useful when a connection is active, but standard). +- **HIG says**: Dock menu entries should mirror commonly used menu items. Apple auto-merges "Open Recent" / "New Window" when present in the main menu of a document-based app — meaning a lot of this disappears for free after the document migration. +- **Fix**: After document migration, the Dock menu's recent-documents section is automatic. Add a "New Query Tab in " item for active sessions. +- **Effort**: S + +### [P2] No `applicationShouldHandleReopen` activation logging — silent no-op when Welcome already visible +- **File**: `TablePro/AppDelegate.swift:27-29`, `TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift:224`. +- **Current**: `applicationShouldHandleReopen` returns whatever `AppLaunchCoordinator.handleReopen` returns. The reopen path opens the Welcome window. Standard behavior, but `WelcomeWindowFactory.openOrFront()` doesn't log when no main windows exist — minor observability gap. +- **Fix**: Add OSLog statement to make the path observable. +- **Effort**: S + +### [P2] `WelcomeWindowFactory.openOrFront()` has no equivalent for the standard "show Open dialog when no docs" flow +- **File**: `TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift:224`. +- **Current**: When the user reopens a document app with no windows, Apple's convention is to show the standard Open dialog (or recent-documents picker), not a custom Welcome panel. TablePro substitutes the Welcome window — fine for connection-first UX, but misses the case where the user really did just want to open a `.sql`. +- **HIG says**: `NSApplicationDelegateOpenUntitledFile` lets the app decide what "untitled" means. For database clients, the Welcome window is a reasonable answer. +- **Fix**: After document model exists, decide whether reopen=Welcome or reopen=Open Recent. Likely keep current behavior; flagged for awareness. +- **Effort**: N/A (decision) + +--- + +## Summary table + +| ID | Severity | Title | File anchor | Effort | +|----|----------|-------|-------------|--------| +| 04-01 | P0 | No `NSDocument` / `FileDocument` for SQL files | `TableProApp.swift:628`, `QueryTabState.swift:257` | L | +| 04-02 | P0 | Open Recent menu is missing entirely | `TableProApp.swift:197`, `TabRouter.swift:322` | M | +| 04-03 | P0 | No auto-save / Versions / Time Machine integration | `MainContentCommandActions.swift:420` | L | +| 04-04 | P0 | File menu missing Revert / Duplicate / Rename / Move To | `TableProApp.swift:204` | S (post-04-01) | +| 04-05 | P0 | Cmd+G / Cmd+Shift+G (find next/previous) not wired | `TableProApp.swift:430` | S | +| 04-06 | P0 | Find submenu missing Use Selection / Jump to Selection | `TableProApp.swift:427` | S | +| 04-07 | P0 | `applicationShouldTerminate` reimplements `NSDocument` quit-review | `AppDelegate.swift:99` | S (post-04-01) | +| 04-08 | P1 | Settings uses pre-Sonoma `TabView` toolbar style | `SettingsView.swift:17` | M | +| 04-09 | P1 | Welcome-springback non-native; should use `applicationShouldTerminateAfterLastWindowClosed` | `AppDelegate.swift:172` | S | +| 04-10 | P1 | Custom Save Changes alert duplicates `NSDocument` | `AlertHelper.swift:130` | S (post-04-01) | +| 04-11 | P1 | iCloud entitlement set but no document iCloud Drive support | `TablePro.entitlements:7` | S | +| 04-12 | P1 | About panel programmatic credits — should ship `Credits.rtf` | `TableProApp.swift:146` | S | +| 04-13 | P1 | Hardcoded English `panel.message` strings | `MainContentCoordinator+SidebarActions.swift:140`, `ImportDialog.swift:301` | S | +| 04-14 | P1 | No `UNUserNotificationCenter` for long queries / sync events | (codebase-wide) | M | +| 04-15 | P1 | No Quick Look extension for `.sql` files | `Info.plist:11`, no QL target | M | +| 04-16 | P1 | App is unsandboxed (App Store readiness, separate project) | `TablePro.entitlements:19` | L | +| 04-17 | P1 | No Services menu integration | (codebase-wide) | S (optional) | +| 04-18 | P1 | Cmd+W and quit-review use different alert copy | `MainContentCommandActions.swift:327`, `AppDelegate.swift:99` | S (post-04-01) | +| 04-19 | P2 | `openSQLFile` round-trips through Notification | `MainContentCommandActions.swift:558` | S | +| 04-20 | P2 | `application(_:open:)` doesn't note recent documents | `AppDelegate.swift:17` | S | +| 04-21 | P2 | `panel.title` set on sheet panels (ignored) | `InstalledPluginsView.swift:377`, `ERDiagramView.swift:285` | S | +| 04-22 | P2 | Dock menu omits Open Recent / New Tab | `AppDelegate.swift:194` | S | +| 04-23 | P2 | `applicationShouldHandleReopen` lacks OSLog tracing | `AppDelegate.swift:27` | S | +| 04-24 | P2 | Reopen flow shows Welcome instead of Open dialog (decision flag) | `AppLaunchCoordinator.swift:224` | N/A | + +## Recommendation + +The headline gap is the **document model**. Fixing 04-01 unlocks 04-02 (Open Recent), 04-03 (auto-save / Versions), 04-04 (File-menu items), 04-07 (quit review), 04-10 (Save Changes alert), 04-18 (Cmd+W consistency), and most of 04-22 in one stroke. Land that first, then mop up Find shortcuts (04-05, 04-06), Settings sidebar (04-08), and Credits.rtf (04-12). + +The unsandboxed build (04-16) is the only item that affects strategic distribution (Mac App Store) and is a separate body of work that should be tracked outside this audit. From c788e71ecf9509263ef0e4418cdea25a1a1a1222 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 18:53:38 +0700 Subject: [PATCH 20/54] chore(mcp): log connection accept + integrations-exchange lifecycle Adds info/warning logs at handleNewConnection and at every branch of handleIntegrationsExchange so we can see whether requests are arriving, whether bodies parse, and whether the handler hits the success or failure path. No behavior change. --- TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift index 5c8bfec61..491912bb7 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -232,6 +232,7 @@ public actor MCPHttpServerTransport { private func handleNewConnection(_ connection: NWConnection) async { let connectionId = UUID() + Self.logger.debug("Accepted connection \(connectionId, privacy: .public)") let context = HttpConnectionContext(id: connectionId, connection: connection) connections[connectionId] = context await context.start { [weak self] data in @@ -332,16 +333,20 @@ public actor MCPHttpServerTransport { let token: String } + Self.logger.info("Integrations exchange request received (\(body.count, privacy: .public) bytes)") + let parsed: ExchangeBody do { parsed = try JSONDecoder().decode(ExchangeBody.self, from: body) } catch { + Self.logger.warning("Integrations exchange decode failed: \(error.localizedDescription, privacy: .public)") await context.writePlainJsonError(status: .badRequest, message: "Invalid JSON body") await context.cancel() return } guard !parsed.code.isEmpty, !parsed.codeVerifier.isEmpty else { + Self.logger.warning("Integrations exchange missing code or verifier") await context.writePlainJsonError(status: .badRequest, message: "Missing code or code_verifier") await context.cancel() return @@ -358,11 +363,13 @@ public actor MCPHttpServerTransport { switch outcome { case .success(let token): + Self.logger.info("Integrations exchange succeeded (token len=\(token.count, privacy: .public))") let payload = (try? JSONEncoder().encode(ExchangeResponse(token: token))) ?? Data() await context.writePlainJsonResponse(status: .ok, body: payload) await context.cancel() case .failure(let error): let mapped = Self.mapExchangeError(error) + Self.logger.warning("Integrations exchange failed: status=\(mapped.status.code, privacy: .public) reason=\(mapped.message, privacy: .public)") await context.writePlainJsonError(status: mapped.status, message: mapped.message) await context.cancel() } From 5d37d76e957e3b92b8e45bc48f51de5afbb3e8bc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:46:36 +0700 Subject: [PATCH 21/54] fix(mcp): use dedicated -32009 unauthenticated JSON-RPC error code --- TablePro/Core/MCP/Transport/MCPProtocolError.swift | 2 +- TablePro/Core/MCP/Wire/JsonRpcErrorCode.swift | 1 + TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift | 1 + .../MCP/Transport/MCPStreamableHttpClientTransportTests.swift | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/MCP/Transport/MCPProtocolError.swift b/TablePro/Core/MCP/Transport/MCPProtocolError.swift index 18e4a6c37..b5e3a9713 100644 --- a/TablePro/Core/MCP/Transport/MCPProtocolError.swift +++ b/TablePro/Core/MCP/Transport/MCPProtocolError.swift @@ -77,7 +77,7 @@ public extension MCPProtocolError { static func unauthenticated(challenge: String = "Bearer realm=\"TablePro\"") -> Self { Self( - code: JsonRpcErrorCode.sessionNotFound, + code: JsonRpcErrorCode.unauthenticated, message: "Unauthenticated", httpStatus: .unauthorized, extraHeaders: [("WWW-Authenticate", challenge)] diff --git a/TablePro/Core/MCP/Wire/JsonRpcErrorCode.swift b/TablePro/Core/MCP/Wire/JsonRpcErrorCode.swift index 3980935b0..bb6059355 100644 --- a/TablePro/Core/MCP/Wire/JsonRpcErrorCode.swift +++ b/TablePro/Core/MCP/Wire/JsonRpcErrorCode.swift @@ -16,6 +16,7 @@ public enum JsonRpcErrorCode { public static let serverDisabled = -32_006 public static let forbidden = -32_007 public static let expired = -32_008 + public static let unauthenticated = -32_009 public static let serverErrorRange: ClosedRange = -32_099 ... -32_000 } diff --git a/TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift b/TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift index f3f8831ca..79695a86c 100644 --- a/TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPProtocolErrorTests.swift @@ -48,6 +48,7 @@ final class MCPProtocolErrorTests: XCTestCase { func testUnauthenticatedIncludesWwwAuthenticate() { let error = MCPProtocolError.unauthenticated(challenge: "Bearer realm=\"x\"") + XCTAssertEqual(error.code, JsonRpcErrorCode.unauthenticated) XCTAssertEqual(error.httpStatus, .unauthorized) let header = error.extraHeaders.first { $0.0.lowercased() == "www-authenticate" } XCTAssertNotNil(header) diff --git a/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift index 6a0e3e974..e37991522 100644 --- a/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift @@ -119,7 +119,7 @@ final class MCPStreamableHttpClientTransportTests: XCTestCase { return } XCTAssertEqual(response.id, .number(99)) - XCTAssertEqual(response.error.code, JsonRpcErrorCode.sessionNotFound) + XCTAssertEqual(response.error.code, JsonRpcErrorCode.unauthenticated) XCTAssertEqual(response.error.message, "Unauthenticated") await transport.close() } From 8dc6876df58b6997ba4abbad9fc79f6e07f21d9f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:46:41 +0700 Subject: [PATCH 22/54] fix(mcp): drop redundant top-level body-size guard and reject Last-Event-ID with 501 --- .../Transport/MCPHttpServerTransport.swift | 48 ++++++++++++++++--- TablePro/Core/MCP/Wire/HttpResponseHead.swift | 1 + 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift index 491912bb7..2171eb567 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -255,11 +255,6 @@ public actor MCPHttpServerTransport { private func handleReceivedData(connectionId: UUID, data: Data) async { guard let context = connections[connectionId] else { return } - if data.count > configuration.limits.maxRequestBodyBytes + configuration.limits.maxHeaderBytes { - await respondTopLevel(context: context, error: .payloadTooLarge(), requestId: nil) - return - } - let parseResult: HttpRequestParseResult do { parseResult = try HttpRequestParser.parse(data) @@ -334,12 +329,14 @@ public actor MCPHttpServerTransport { } Self.logger.info("Integrations exchange request received (\(body.count, privacy: .public) bytes)") + let ip = Self.ipString(for: await context.clientAddress()) let parsed: ExchangeBody do { parsed = try JSONDecoder().decode(ExchangeBody.self, from: body) } catch { Self.logger.warning("Integrations exchange decode failed: \(error.localizedDescription, privacy: .public)") + MCPAuditLogger.logPairingExchange(outcome: .denied, ip: ip, details: "invalid JSON body") await context.writePlainJsonError(status: .badRequest, message: "Invalid JSON body") await context.cancel() return @@ -347,6 +344,11 @@ public actor MCPHttpServerTransport { guard !parsed.code.isEmpty, !parsed.codeVerifier.isEmpty else { Self.logger.warning("Integrations exchange missing code or verifier") + MCPAuditLogger.logPairingExchange( + outcome: .denied, + ip: ip, + details: "missing code or code_verifier" + ) await context.writePlainJsonError(status: .badRequest, message: "Missing code or code_verifier") await context.cancel() return @@ -364,17 +366,39 @@ public actor MCPHttpServerTransport { switch outcome { case .success(let token): Self.logger.info("Integrations exchange succeeded (token len=\(token.count, privacy: .public))") + let label = await Self.resolveTokenLabel(for: token) + MCPAuditLogger.logPairingExchange(outcome: .success, tokenName: label, ip: ip) let payload = (try? JSONEncoder().encode(ExchangeResponse(token: token))) ?? Data() await context.writePlainJsonResponse(status: .ok, body: payload) await context.cancel() case .failure(let error): let mapped = Self.mapExchangeError(error) Self.logger.warning("Integrations exchange failed: status=\(mapped.status.code, privacy: .public) reason=\(mapped.message, privacy: .public)") + MCPAuditLogger.logPairingExchange( + outcome: .denied, + ip: ip, + details: mapped.message + ) await context.writePlainJsonError(status: mapped.status, message: mapped.message) await context.cancel() } } + private static func ipString(for address: MCPClientAddress) -> String { + switch address { + case .loopback: + return "127.0.0.1" + case .remote(let host): + return host + } + } + + private static func resolveTokenLabel(for plaintext: String) async -> String? { + let store: MCPTokenStore? = await MainActor.run { MCPServerManager.shared.tokenStore } + guard let store else { return nil } + return await store.validate(bearerToken: plaintext)?.name + } + private static func mapExchangeError(_ error: Error) -> (status: HttpStatus, message: String) { guard let domainError = error as? MCPDataLayerError else { return (.internalServerError, "Internal error") @@ -431,7 +455,19 @@ public actor MCPHttpServerTransport { await sessionStore.touch(id: sessionId) - _ = head.headers.value(for: "Last-Event-ID") + if head.headers.value(for: "Last-Event-ID") != nil { + await respondTopLevel( + context: context, + error: MCPProtocolError( + code: JsonRpcErrorCode.serverError, + message: "SSE event replay is not supported", + httpStatus: .notImplemented + ), + requestId: nil + ) + return + } + let mcpProtocolVersion = head.headers.value(for: "mcp-protocol-version") let sink = TransportResponderSink(transport: self, context: context) diff --git a/TablePro/Core/MCP/Wire/HttpResponseHead.swift b/TablePro/Core/MCP/Wire/HttpResponseHead.swift index e0e0db861..3c48968ce 100644 --- a/TablePro/Core/MCP/Wire/HttpResponseHead.swift +++ b/TablePro/Core/MCP/Wire/HttpResponseHead.swift @@ -22,6 +22,7 @@ public struct HttpStatus: Sendable, Equatable { public static let unsupportedMediaType = HttpStatus(code: 415, reasonPhrase: "Unsupported Media Type") public static let tooManyRequests = HttpStatus(code: 429, reasonPhrase: "Too Many Requests") public static let internalServerError = HttpStatus(code: 500, reasonPhrase: "Internal Server Error") + public static let notImplemented = HttpStatus(code: 501, reasonPhrase: "Not Implemented") public static let serviceUnavailable = HttpStatus(code: 503, reasonPhrase: "Service Unavailable") } From 130e1ccce9fa68287c8114411f709d9cea5e45cc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:46:45 +0700 Subject: [PATCH 23/54] fix(mcp): reuse transport-allocated session on initialize and surface session errors --- .../MCP/Protocol/MCPProtocolDispatcher.swift | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift b/TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift index 87015af7f..4c4632107 100644 --- a/TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift +++ b/TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift @@ -156,7 +156,13 @@ public actor MCPProtocolDispatcher { let session = await sessionStore.session(id: sessionId) { let state = await session.state if case .initializing = state { - try? await session.transitionToReady() + do { + try await session.transitionToReady() + } catch { + Self.logger.warning( + "Failed to transition session to ready: \(error.localizedDescription, privacy: .public)" + ) + } } } await exchange.responder.acknowledgeAccepted() @@ -193,7 +199,18 @@ public actor MCPProtocolDispatcher { private func resolveOrCreateSession(method: String, exchange: MCPInboundExchange) async -> MCPSession? { if method == "initialize" { - return try? await sessionStore.create() + if let sessionId = exchange.context.sessionId, + let existing = await sessionStore.session(id: sessionId) { + return existing + } + do { + return try await sessionStore.create() + } catch { + Self.logger.warning( + "Failed to create session: \(error.localizedDescription, privacy: .public)" + ) + return nil + } } guard let sessionId = exchange.context.sessionId else { return nil } From 5e8192635de8fa5adebfe3af95d47aa73fbdbe6b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:46:49 +0700 Subject: [PATCH 24/54] fix(mcp): serialize HTTP client writes through chained Task pipeline --- .../MCPStreamableHttpClientTransport.swift | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift b/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift index dcee9da4d..a47f0ed7f 100644 --- a/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift @@ -387,8 +387,26 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { } private actor HttpWriter { - func serialize(_ work: @Sendable () async throws -> T) async throws -> T { - try await work() + private var pending: Task? + + func serialize(_ work: @Sendable @escaping () async throws -> T) async throws -> T { + let previous = pending + let result: Result = await withCheckedContinuation { continuation in + let task = Task { [previous] in + _ = await previous?.value + do { + let value = try await work() + continuation.resume(returning: .success(value)) + } catch { + continuation.resume(returning: .failure(error)) + } + } + self.pending = task + } + switch result { + case .success(let value): return value + case .failure(let error): throw error + } } } From bceb73ad17bdba8270c331f260a51f42ef1b395a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:46:53 +0700 Subject: [PATCH 25/54] fix(mcp): write stderr bridge logs synchronously to avoid dropped lines on exit --- .../Core/MCP/Transport/MCPBridgeLogger.swift | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift b/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift index fa19fde32..7c09ca7fd 100644 --- a/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift +++ b/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift @@ -34,11 +34,9 @@ public struct MCPOSBridgeLogger: MCPBridgeLogger { } public struct MCPStderrBridgeLogger: MCPBridgeLogger { - private let writer: StderrWriter + private static let lock = NSLock() - public init() { - writer = StderrWriter() - } + public init() {} public func log(_ level: MCPBridgeLogLevel, _ message: String) { let prefix: String @@ -49,10 +47,10 @@ public struct MCPStderrBridgeLogger: MCPBridgeLogger { case .error: prefix = "[error] " } let payload = prefix + message + "\n" - let target = writer - Task { - await target.write(payload) - } + guard let data = payload.data(using: .utf8) else { return } + Self.lock.lock() + defer { Self.lock.unlock() } + FileHandle.standardError.write(data) } } @@ -70,15 +68,3 @@ public struct MCPCompositeBridgeLogger: MCPBridgeLogger { } } -private actor StderrWriter { - private let handle: FileHandle - - init(handle: FileHandle = .standardError) { - self.handle = handle - } - - func write(_ string: String) { - guard let data = string.data(using: .utf8) else { return } - handle.write(data) - } -} From 6a0be56e5ff7f134ed45467ccb7cc0e90baf17a9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:46:57 +0700 Subject: [PATCH 26/54] fix(mcp): encode handshake file via Codable struct shared with bridge decoder --- TablePro/Core/MCP/MCPServerManager.swift | 31 +++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/TablePro/Core/MCP/MCPServerManager.swift b/TablePro/Core/MCP/MCPServerManager.swift index d08770cb4..947db2d2e 100644 --- a/TablePro/Core/MCP/MCPServerManager.swift +++ b/TablePro/Core/MCP/MCPServerManager.swift @@ -343,20 +343,27 @@ final class MCPServerManager { "\(handshakeDirectoryPath)/mcp-handshake.json" }() + private struct HandshakeFilePayload: Encodable { + let port: Int + let token: String + let pid: Int32 + let protocolVersion: String + let tls: Bool + let tlsCertFingerprint: String? + } + private func writeHandshakeFile(port: UInt16, tlsCertFingerprint: String? = nil) { guard let bridgeToken = internalBridgeToken else { return } let settings = AppSettingsManager.shared.mcp - var handshake: [String: Any] = [ - "port": Int(port), - "token": bridgeToken, - "pid": ProcessInfo.processInfo.processIdentifier, - "protocolVersion": InitializeHandler.supportedProtocolVersion, - "tls": settings.allowRemoteConnections - ] - if let tlsCertFingerprint { - handshake["tlsCertFingerprint"] = tlsCertFingerprint - } + let payload = HandshakeFilePayload( + port: Int(port), + token: bridgeToken, + pid: ProcessInfo.processInfo.processIdentifier, + protocolVersion: InitializeHandler.supportedProtocolVersion, + tls: settings.allowRemoteConnections, + tlsCertFingerprint: tlsCertFingerprint + ) let fileManager = FileManager.default let directory = Self.handshakeDirectoryPath @@ -373,7 +380,9 @@ final class MCPServerManager { ) } - let data = try JSONSerialization.data(withJSONObject: handshake, options: [.sortedKeys]) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(payload) let url = URL(fileURLWithPath: Self.handshakeFilePath) try data.write(to: url, options: [.atomic]) try fileManager.setAttributes( From c59c12a5d7306412641f9e57c2ceb0f6f0bc942f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:47:02 +0700 Subject: [PATCH 27/54] chore(mcp): drop unused Network import from MCPInboundExchange --- TablePro/Core/MCP/Transport/MCPInboundExchange.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/TablePro/Core/MCP/Transport/MCPInboundExchange.swift b/TablePro/Core/MCP/Transport/MCPInboundExchange.swift index b284a651e..8cb3a1dd5 100644 --- a/TablePro/Core/MCP/Transport/MCPInboundExchange.swift +++ b/TablePro/Core/MCP/Transport/MCPInboundExchange.swift @@ -1,5 +1,4 @@ import Foundation -import Network import os public struct MCPInboundContext: Sendable { From fdd44ea33b8c2d0832e65e767279f201b9b30f64 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:47:07 +0700 Subject: [PATCH 28/54] fix(mcp): localize tool inputSchema descriptions --- .../Core/MCP/Protocol/Tools/DescribeTableTool.swift | 6 +++--- .../MCP/Protocol/Tools/GetConnectionStatusTool.swift | 2 +- TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift | 6 +++--- .../Core/MCP/Protocol/Tools/ListDatabasesTool.swift | 2 +- TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift | 4 ++-- TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift | 8 ++++---- .../MCP/Protocol/Tools/SearchQueryHistoryTool.swift | 10 +++++----- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift b/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift index 90a80682f..8cb3369d7 100644 --- a/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift @@ -12,15 +12,15 @@ public struct DescribeTableTool: MCPToolImplementation { "properties": .object([ "connection_id": .object([ "type": .string("string"), - "description": .string("UUID of the connection") + "description": .string(String(localized: "UUID of the connection")) ]), "table": .object([ "type": .string("string"), - "description": .string("Table name") + "description": .string(String(localized: "Table name")) ]), "schema": .object([ "type": .string("string"), - "description": .string("Schema name (uses current if omitted)") + "description": .string(String(localized: "Schema name (uses current if omitted)")) ]) ]), "required": .array([.string("connection_id"), .string("table")]) diff --git a/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift b/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift index 749dca074..7f81b26d8 100644 --- a/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift @@ -10,7 +10,7 @@ public struct GetConnectionStatusTool: MCPToolImplementation { "properties": .object([ "connection_id": .object([ "type": .string("string"), - "description": .string("UUID of the connection") + "description": .string(String(localized: "UUID of the connection")) ]) ]), "required": .array([.string("connection_id")]) diff --git a/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift b/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift index 886879e05..40815a703 100644 --- a/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift @@ -10,15 +10,15 @@ public struct GetTableDdlTool: MCPToolImplementation { "properties": .object([ "connection_id": .object([ "type": .string("string"), - "description": .string("UUID of the connection") + "description": .string(String(localized: "UUID of the connection")) ]), "table": .object([ "type": .string("string"), - "description": .string("Table name") + "description": .string(String(localized: "Table name")) ]), "schema": .object([ "type": .string("string"), - "description": .string("Schema name (uses current if omitted)") + "description": .string(String(localized: "Schema name (uses current if omitted)")) ]) ]), "required": .array([.string("connection_id"), .string("table")]) diff --git a/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift index be4e433d7..af56b963e 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift @@ -10,7 +10,7 @@ public struct ListDatabasesTool: MCPToolImplementation { "properties": .object([ "connection_id": .object([ "type": .string("string"), - "description": .string("UUID of the connection") + "description": .string(String(localized: "UUID of the connection")) ]) ]), "required": .array([.string("connection_id")]) diff --git a/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift index 8484ad7de..ada6ca116 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift @@ -10,11 +10,11 @@ public struct ListSchemasTool: MCPToolImplementation { "properties": .object([ "connection_id": .object([ "type": .string("string"), - "description": .string("UUID of the connection") + "description": .string(String(localized: "UUID of the connection")) ]), "database": .object([ "type": .string("string"), - "description": .string("Database name (uses current if omitted)") + "description": .string(String(localized: "Database name (uses current if omitted)")) ]) ]), "required": .array([.string("connection_id")]) diff --git a/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift index e390331e8..8ab085a45 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift @@ -10,19 +10,19 @@ public struct ListTablesTool: MCPToolImplementation { "properties": .object([ "connection_id": .object([ "type": .string("string"), - "description": .string("UUID of the connection") + "description": .string(String(localized: "UUID of the connection")) ]), "database": .object([ "type": .string("string"), - "description": .string("Database name (uses current if omitted)") + "description": .string(String(localized: "Database name (uses current if omitted)")) ]), "schema": .object([ "type": .string("string"), - "description": .string("Schema name (uses current if omitted)") + "description": .string(String(localized: "Schema name (uses current if omitted)")) ]), "include_row_counts": .object([ "type": .string("boolean"), - "description": .string("Include approximate row counts (default false)") + "description": .string(String(localized: "Include approximate row counts (default false)")) ]) ]), "required": .array([.string("connection_id")]) diff --git a/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift b/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift index 79a28f18d..40cc81b49 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift @@ -12,23 +12,23 @@ public struct SearchQueryHistoryTool: MCPToolImplementation { "properties": .object([ "query": .object([ "type": .string("string"), - "description": .string("Search text (full-text matched against the query column)") + "description": .string(String(localized: "Search text (full-text matched against the query column)")) ]), "connection_id": .object([ "type": .string("string"), - "description": .string("Restrict to a specific connection (UUID, optional)") + "description": .string(String(localized: "Restrict to a specific connection (UUID, optional)")) ]), "limit": .object([ "type": .string("integer"), - "description": .string("Maximum number of entries to return (default 50, max 500)") + "description": .string(String(localized: "Maximum number of entries to return (default 50, max 500)")) ]), "since": .object([ "type": .string("number"), - "description": .string("Earliest executed_at to include, Unix epoch seconds (inclusive, optional)") + "description": .string(String(localized: "Earliest executed_at to include, Unix epoch seconds (inclusive, optional)")) ]), "until": .object([ "type": .string("number"), - "description": .string("Latest executed_at to include, Unix epoch seconds (inclusive, optional)") + "description": .string(String(localized: "Latest executed_at to include, Unix epoch seconds (inclusive, optional)")) ]) ]), "required": .array([.string("query")]) From ae8f7ed1a7f4f9fb6edc77bbd19be7c51768faab Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:47:10 +0700 Subject: [PATCH 29/54] perf(mcp): replace linear tool lookup with precomputed dictionary --- TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift b/TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift index 233e12f04..4b2cde4cc 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPToolRegistry.swift @@ -23,7 +23,15 @@ public enum MCPToolRegistry { OpenConnectionWindowTool() ] + private static let toolsByName: [String: any MCPToolImplementation] = { + var map: [String: any MCPToolImplementation] = [:] + for tool in allTools { + map[type(of: tool).name] = tool + } + return map + }() + public static func tool(named name: String) -> (any MCPToolImplementation)? { - allTools.first { type(of: $0).name == name } + toolsByName[name] } } From 523c1719ce65fd679449413a68347f475cf24e71 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:58:06 +0700 Subject: [PATCH 30/54] chore(mcp): add pairing exchange audit logger + thousands separators --- TablePro/Core/MCP/MCPAuditLogger.swift | 39 +++ .../Protocol/Tools/ToolQueryExecutor.swift | 4 +- .../MCPHttpServerTransportPairingTests.swift | 277 ++++++++++++++++++ 3 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 TableProTests/Core/MCP/Transport/MCPHttpServerTransportPairingTests.swift diff --git a/TablePro/Core/MCP/MCPAuditLogger.swift b/TablePro/Core/MCP/MCPAuditLogger.swift index f5f53a95f..f92f3e5a2 100644 --- a/TablePro/Core/MCP/MCPAuditLogger.swift +++ b/TablePro/Core/MCP/MCPAuditLogger.swift @@ -44,6 +44,45 @@ enum MCPAuditLogger { ) } + static func logPairingExchange( + outcome: AuditOutcome, + tokenName: String? = nil, + ip: String, + details: String? = nil + ) { + let resolvedDetails = Self.composePairingDetails(ip: ip, extra: details) + switch outcome { + case .success: + serverAuth.info( + "Pairing exchange success: token=\(tokenName ?? "-", privacy: .public) ip=\(ip, privacy: .public)" + ) + case .denied: + serverAuth.warning( + "Pairing exchange denied: ip=\(ip, privacy: .public) details=\(details ?? "-", privacy: .public)" + ) + case .rateLimited: + serverAuth.warning("Pairing exchange rate limited: ip=\(ip, privacy: .public)") + case .error: + serverAuth.error( + "Pairing exchange error: ip=\(ip, privacy: .public) details=\(details ?? "-", privacy: .public)" + ) + } + record( + category: .auth, + tokenName: tokenName, + action: "pairing.exchange", + outcome: outcome, + details: resolvedDetails + ) + } + + private static func composePairingDetails(ip: String, extra: String?) -> String { + guard let extra, !extra.isEmpty else { + return "ip=\(ip)" + } + return "ip=\(ip) \(extra)" + } + static func logTokenCreated(tokenName: String) { serverAdmin.info("Token created: \(tokenName, privacy: .public)") record( diff --git a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift index fac07a71b..55d399e08 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift @@ -34,7 +34,7 @@ enum ToolQueryExecutor { tokenName: principalLabel, connectionId: connectionId, sql: query, - durationMs: Int(elapsed * 1000), + durationMs: Int(elapsed * 1_000), rowCount: rowCount, outcome: .success ) @@ -55,7 +55,7 @@ enum ToolQueryExecutor { tokenName: principalLabel, connectionId: connectionId, sql: query, - durationMs: Int(elapsed * 1000), + durationMs: Int(elapsed * 1_000), rowCount: 0, outcome: .error, errorMessage: error.localizedDescription diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportPairingTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportPairingTests.swift new file mode 100644 index 000000000..7e66f68d4 --- /dev/null +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportPairingTests.swift @@ -0,0 +1,277 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("MCP HTTP Server Transport Pairing") +struct MCPHttpServerTransportPairingTests { + private struct ExchangeError: Decodable { + let error: String + } + + private struct ExchangeResponse: Decodable { + let token: String + } + + private func makeTransport( + authenticator: any MCPAuthenticator, + clock: any MCPClock = MCPSystemClock() + ) -> (MCPHttpServerTransport, MCPSessionStore) { + let policy = MCPSessionPolicy( + idleTimeout: .seconds(900), + maxSessions: 16, + cleanupInterval: .seconds(60) + ) + let store = MCPSessionStore(policy: policy, clock: clock) + let config = MCPHttpServerConfiguration.loopback(port: 0) + let transport = MCPHttpServerTransport( + configuration: config, + sessionStore: store, + authenticator: authenticator, + clock: clock + ) + return (transport, store) + } + + private func startedTransport( + authenticator: any MCPAuthenticator, + clock: any MCPClock = MCPSystemClock() + ) async throws -> (MCPHttpServerTransport, UInt16) { + let (transport, _) = makeTransport(authenticator: authenticator, clock: clock) + let stateStream = transport.listenerState + let stateTask = Task { + for await state in stateStream { + if case .running(let port) = state { + return port + } + if case .failed = state { + return nil + } + } + return nil + } + try await transport.start() + guard let port = await stateTask.value, port != 0 else { + await transport.stop() + throw PairingTestError.serverDidNotStart + } + return (transport, port) + } + + private func makeExchangeRequest( + port: UInt16, + body: Data?, + contentType: String = "application/json" + ) -> URLRequest { + guard let url = URL(string: "http://127.0.0.1:\(port)/v1/integrations/exchange") else { + fatalError("Failed to construct test URL") + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + if let body { + request.httpBody = body + } + return request + } + + private func insertPairingRecord( + code: String, + plaintextToken: String, + challenge: String, + expiresAt: Date + ) async throws { + try await MainActor.run { + try MCPPairingService.shared.store.insert( + code: code, + record: PairingExchangeRecord( + plaintextToken: plaintextToken, + challenge: challenge, + expiresAt: expiresAt + ) + ) + } + } + + private func clearPairingCode(_ code: String) async { + await MainActor.run { + _ = try? MCPPairingService.shared.store.consume(code: code, verifier: "__cleanup__") + } + } + + private func uniqueCode() -> String { + "test-code-\(UUID().uuidString)" + } + + private func challenge(for verifier: String) -> String { + PairingExchangeStore.sha256Base64Url(of: verifier) + } + + @Test("Empty body returns 400 with invalid JSON body error") + func emptyBodyReturnsBadRequest() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let request = makeExchangeRequest(port: port, body: Data()) + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 400) + let decoded = try JSONDecoder().decode(ExchangeError.self, from: data) + #expect(decoded.error == "Invalid JSON body") + } + + @Test("Malformed JSON returns 400 with invalid JSON body error") + func malformedJsonReturnsBadRequest() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let body = Data("{not-json".utf8) + let request = makeExchangeRequest(port: port, body: body) + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 400) + let decoded = try JSONDecoder().decode(ExchangeError.self, from: data) + #expect(decoded.error == "Invalid JSON body") + } + + @Test("Missing code returns 400 with missing code error") + func missingCodeReturnsBadRequest() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let body = Data(#"{"code":"","code_verifier":"verifier"}"#.utf8) + let request = makeExchangeRequest(port: port, body: body) + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 400) + let decoded = try JSONDecoder().decode(ExchangeError.self, from: data) + #expect(decoded.error == "Missing code or code_verifier") + } + + @Test("Missing code_verifier returns 400 with missing code error") + func missingCodeVerifierReturnsBadRequest() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let body = Data(#"{"code":"abc","code_verifier":""}"#.utf8) + let request = makeExchangeRequest(port: port, body: body) + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 400) + let decoded = try JSONDecoder().decode(ExchangeError.self, from: data) + #expect(decoded.error == "Missing code or code_verifier") + } + + @Test("Unknown code returns 404 with not-found error") + func unknownCodeReturnsNotFound() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let synthetic = "synthetic-\(UUID().uuidString)" + let body = Data(#"{"code":"\#(synthetic)","code_verifier":"any-verifier"}"#.utf8) + let request = makeExchangeRequest(port: port, body: body) + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 404) + let decoded = try JSONDecoder().decode(ExchangeError.self, from: data) + #expect(decoded.error == "Pairing code not found") + } + + @Test("Successful exchange returns 200 with token in body") + func successfulExchangeReturnsToken() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let code = uniqueCode() + let verifier = "verifier-\(UUID().uuidString)" + let plaintext = "tp_test-token-\(UUID().uuidString)" + try await insertPairingRecord( + code: code, + plaintextToken: plaintext, + challenge: challenge(for: verifier), + expiresAt: Date.now.addingTimeInterval(60) + ) + defer { Task { await clearPairingCode(code) } } + + let payload = ["code": code, "code_verifier": verifier] + let body = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + + let request = makeExchangeRequest(port: port, body: body) + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 200) + let decoded = try JSONDecoder().decode(ExchangeResponse.self, from: data) + #expect(decoded.token == plaintext) + } + + @Test("Mismatched verifier returns 403 with challenge mismatch error") + func mismatchedVerifierReturnsForbidden() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let code = uniqueCode() + let realVerifier = "real-verifier-\(UUID().uuidString)" + try await insertPairingRecord( + code: code, + plaintextToken: "tp_test", + challenge: challenge(for: realVerifier), + expiresAt: Date.now.addingTimeInterval(60) + ) + defer { Task { await clearPairingCode(code) } } + + let payload = ["code": code, "code_verifier": "wrong-verifier"] + let body = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + + let request = makeExchangeRequest(port: port, body: body) + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 403) + let decoded = try JSONDecoder().decode(ExchangeError.self, from: data) + #expect(decoded.error == "Challenge mismatch") + } + + @Test("Expired pairing code is unredeemable") + func expiredCodeIsUnredeemable() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let code = uniqueCode() + let verifier = "verifier-\(UUID().uuidString)" + try await insertPairingRecord( + code: code, + plaintextToken: "tp_test", + challenge: challenge(for: verifier), + expiresAt: Date.now.addingTimeInterval(-60) + ) + defer { Task { await clearPairingCode(code) } } + + let payload = ["code": code, "code_verifier": verifier] + let body = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + + let request = makeExchangeRequest(port: port, body: body) + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 410 || http.statusCode == 404) + let decoded = try JSONDecoder().decode(ExchangeError.self, from: data) + #expect(decoded.error == "Pairing code expired" || decoded.error == "Pairing code not found") + } +} + +private enum PairingTestError: Error { + case serverDidNotStart +} From 5ee99f7596be0d9a3a68d704e5f484bf48563e62 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:58:10 +0700 Subject: [PATCH 31/54] fix(mcp): wire GET /mcp directly to SSE notification stream registration --- .../Transport/MCPHttpServerTransport.swift | 62 ++++++++----------- .../MCPHttpServerTransportTests.swift | 52 ++++++++++++++++ 2 files changed, 78 insertions(+), 36 deletions(-) diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift index 2171eb567..c6747c1df 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -297,7 +297,7 @@ public actor MCPHttpServerTransport { await context.cancel() return case .get: - await handleGetMcp(head: head, body: body, context: context, clientAddress: clientAddress, now: now) + await handleGetMcp(head: head, context: context, clientAddress: clientAddress) case .post: await handlePostMcp(head: head, body: body, context: context, clientAddress: clientAddress, now: now) case .delete: @@ -417,10 +417,8 @@ public actor MCPHttpServerTransport { private func handleGetMcp( head: HttpRequestHead, - body: Data, context: HttpConnectionContext, - clientAddress: MCPClientAddress, - now: Date + clientAddress: MCPClientAddress ) async { guard pathMatchesMcp(head.path) else { await respondTopLevel( @@ -435,25 +433,10 @@ public actor MCPHttpServerTransport { return } - let authResult = await authenticate(headers: head.headers, clientAddress: clientAddress) - guard case .allow(let principal) = authResult else { - if case .deny(let error) = authResult { - await respondTopLevel(context: context, error: error, requestId: nil) - } - return - } - guard let sessionIdRaw = head.headers.value(for: "Mcp-Session-Id") else { await respondTopLevel(context: context, error: .missingSessionId(), requestId: nil) return } - let sessionId = MCPSessionId(sessionIdRaw) - guard await sessionStore.session(id: sessionId) != nil else { - await respondTopLevel(context: context, error: .sessionNotFound(), requestId: nil) - return - } - - await sessionStore.touch(id: sessionId) if head.headers.value(for: "Last-Event-ID") != nil { await respondTopLevel( @@ -468,25 +451,32 @@ public actor MCPHttpServerTransport { return } - let mcpProtocolVersion = head.headers.value(for: "mcp-protocol-version") + if let accept = head.headers.value(for: "Accept"), + !accept.lowercased().contains("text/event-stream"), + !accept.contains("*/*") { + await respondTopLevel(context: context, error: .notAcceptable(), requestId: nil) + return + } - let sink = TransportResponderSink(transport: self, context: context) - let responder = MCPExchangeResponder(sink: sink, requestId: nil) + let authResult = await authenticate(headers: head.headers, clientAddress: clientAddress) + guard case .allow = authResult else { + if case .deny(let error) = authResult { + await respondTopLevel(context: context, error: error, requestId: nil) + } + return + } - let placeholderRequest = JsonRpcRequest(id: .null, method: "$/sse-stream", params: nil) - let exchangeContext = MCPInboundContext( - sessionId: sessionId, - principal: principal, - clientAddress: clientAddress, - receivedAt: now, - mcpProtocolVersion: mcpProtocolVersion - ) - let exchange = MCPInboundExchange( - message: .request(placeholderRequest), - context: exchangeContext, - responder: responder - ) - exchangesContinuation?.yield(exchange) + let sessionId = MCPSessionId(sessionIdRaw) + guard await sessionStore.session(id: sessionId) != nil else { + await respondTopLevel(context: context, error: .sessionNotFound(), requestId: nil) + return + } + + await sessionStore.touch(id: sessionId) + + registerSseConnection(connectionId: context.id, sessionId: sessionId) + await context.writeSseStreamHeaders(sessionId: sessionId) + Self.logger.info("Registered SSE notification stream for session \(sessionId.rawValue, privacy: .public)") } private func handlePostMcp( diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift index c007b02a6..55c940a40 100644 --- a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift @@ -372,6 +372,58 @@ struct MCPHttpServerTransportTests { #expect(allowHeaders?.contains("Last-Event-ID") == true) } + @Test("GET /mcp opens an SSE stream that delivers server-sent notifications") + func getMcpStreamsServerNotifications() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + await runEchoLoop(transport: transport, consumer: consumer) + defer { Task { await consumer.stop() } } + + let initBody = try makeRequestBody(method: "initialize") + let (_, initResponse) = try await URLSession.shared.data(for: makePost(port: port, body: initBody)) + let initHttp = try #require(initResponse as? HTTPURLResponse) + let sessionId = try #require(initHttp.value(forHTTPHeaderField: "Mcp-Session-Id")) + + guard let url = URL(string: "http://127.0.0.1:\(port)/mcp") else { + Issue.record("Failed to construct URL") + return + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") + request.setValue("Bearer test-token", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 5 + + let session = URLSession(configuration: .ephemeral) + let streamTask = Task<(Int, String), Error> { + let (bytes, response) = try await session.bytes(for: request) + let httpResponse = response as? HTTPURLResponse + var collected = "" + for try await line in bytes.lines { + collected += line + "\n" + if collected.contains("notifications/test") { break } + } + return (httpResponse?.statusCode ?? 0, collected) + } + + try await Task.sleep(for: .milliseconds(200)) + + let notification = JsonRpcNotification( + method: "notifications/test", + params: .object(["progress": .double(0.5)]) + ) + await transport.sendNotification(notification, toSession: MCPSessionId(sessionId)) + + let (status, body) = try await streamTask.value + #expect(status == 200) + #expect(body.contains("notifications/test")) + session.invalidateAndCancel() + } + @Test("Idle session eviction terminates SSE-tracked sessions") func idleSessionEviction() async throws { let clock = MCPTestClock(start: Date(timeIntervalSince1970: 1_000_000)) From f4d62059fcd5dfe52954b1dabc6147e5e9bb4b82 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 19:58:48 +0700 Subject: [PATCH 32/54] fix(mcp): dispatch each exchange in a child task to avoid serializing tool calls --- TablePro/Core/MCP/MCPServerManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/MCP/MCPServerManager.swift b/TablePro/Core/MCP/MCPServerManager.swift index 947db2d2e..69f93b8b8 100644 --- a/TablePro/Core/MCP/MCPServerManager.swift +++ b/TablePro/Core/MCP/MCPServerManager.swift @@ -218,7 +218,7 @@ final class MCPServerManager { for await exchange in transport.exchanges { guard let self else { return } guard await self.isCurrentGeneration(generation) else { return } - await dispatcher.dispatch(exchange) + Task { await dispatcher.dispatch(exchange) } } } } From 1d64e9ed969bc22bf8c03e25dff2776657a4b6e3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:01:10 +0700 Subject: [PATCH 33/54] fix(mcp): negotiate initialize protocolVersion and validate header on follow-ups --- .../Protocol/Handlers/InitializeHandler.swift | 26 +++++- .../Transport/MCPHttpServerTransport.swift | 23 ++++- .../MCP/Helpers/MCPTransportTestStubs.swift | 4 + .../MCPHttpServerTransportTests.swift | 90 +++++++++++++++++++ 4 files changed, 139 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift index f64b0ca2c..1641c1ddc 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift @@ -7,13 +7,21 @@ public struct InitializeHandler: MCPMethodHandler { public static let allowedSessionStates: Set = [.uninitialized] public static let supportedProtocolVersion = "2025-03-26" + public static let supportedProtocolVersions: Set = ["2025-03-26"] private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Handler.Initialize") public init() {} public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { - let protocolVersion = params?["protocolVersion"]?.stringValue ?? Self.supportedProtocolVersion + let requestedVersion = params?["protocolVersion"]?.stringValue + let negotiatedVersion = Self.negotiate(requestedVersion: requestedVersion) + guard let protocolVersion = negotiatedVersion else { + let supported = Self.supportedProtocolVersions.sorted().joined(separator: ", ") + let detail = "Unsupported protocolVersion: \(requestedVersion ?? "missing"). Server supports: \(supported)" + throw MCPProtocolError.invalidRequest(detail: detail) + } + let clientCapabilities = params?["capabilities"] let clientName = params?["clientInfo"]?["name"]?.stringValue ?? "unknown" let clientVersion = params?["clientInfo"]?["version"]?.stringValue @@ -26,7 +34,7 @@ public struct InitializeHandler: MCPMethodHandler { ) let result: JsonValue = .object([ - "protocolVersion": .string(Self.supportedProtocolVersion), + "protocolVersion": .string(protocolVersion), "capabilities": .object([ "tools": .object(["listChanged": .bool(false)]), "resources": .object([ @@ -42,7 +50,19 @@ public struct InitializeHandler: MCPMethodHandler { ]) ]) - Self.logger.info("Initialize: client=\(clientName, privacy: .public) version=\(clientVersion ?? "-", privacy: .public)") + Self.logger.info( + "Initialize: client=\(clientName, privacy: .public) version=\(clientVersion ?? "-", privacy: .public) protocol=\(protocolVersion, privacy: .public)" + ) return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) } + + private static func negotiate(requestedVersion: String?) -> String? { + guard let requestedVersion, !requestedVersion.isEmpty else { + return supportedProtocolVersion + } + if supportedProtocolVersions.contains(requestedVersion) { + return requestedVersion + } + return nil + } } diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift index c6747c1df..24459289e 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -547,10 +547,17 @@ public actor MCPHttpServerTransport { return } let candidate = MCPSessionId(raw) - guard await sessionStore.session(id: candidate) != nil else { + guard let session = await sessionStore.session(id: candidate) else { await respondTopLevel(context: context, error: .sessionNotFound(), requestId: requestId) return } + if let mismatch = await Self.protocolVersionMismatch( + session: session, + headerValue: mcpProtocolVersion + ) { + await respondTopLevel(context: context, error: mismatch, requestId: requestId) + return + } sessionId = candidate await sessionStore.touch(id: candidate) } @@ -681,6 +688,20 @@ public actor MCPHttpServerTransport { return trimmed == "/mcp" || trimmed == "/mcp/" } + private static func protocolVersionMismatch( + session: MCPSession, + headerValue: String? + ) async -> MCPProtocolError? { + let state = await session.state + guard case .ready = state else { return nil } + guard let negotiated = await session.negotiatedProtocolVersion else { return nil } + guard let headerValue, !headerValue.isEmpty else { return nil } + if headerValue == negotiated { return nil } + return .invalidRequest( + detail: "MCP-Protocol-Version mismatch: client sent \(headerValue), session negotiated \(negotiated)" + ) + } + private func stripQueryString(_ path: String) -> String { if let questionIndex = path.firstIndex(of: "?") { return String(path[path.startIndex..? diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift index 55c940a40..746b4a2f0 100644 --- a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift @@ -372,6 +372,96 @@ struct MCPHttpServerTransportTests { #expect(allowHeaders?.contains("Last-Event-ID") == true) } + @Test("Initialize with unsupported protocolVersion returns invalid_request error") + func initializeRejectsUnsupportedProtocolVersion() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + let store = MCPSessionStore() + let progressSink = NullProgressSink() + let dispatcher = MCPProtocolDispatcher( + handlers: [InitializeHandler()], + sessionStore: store, + progressSink: progressSink + ) + await consumer.start(transport: transport) { exchange in + await dispatcher.dispatch(exchange) + } + defer { Task { await consumer.stop() } } + + let request = JsonRpcRequest( + id: .number(1), + method: "initialize", + params: .object(["protocolVersion": .string("1999-01-01")]) + ) + let body = try JsonRpcCodec.encode(.request(request)) + let httpRequest = makePost(port: port, body: body) + let (data, response) = try await URLSession.shared.data(for: httpRequest) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 400) + let parsed = try parseJsonRpcError(data) + #expect(parsed.code == JsonRpcErrorCode.invalidRequest) + } + + @Test("Subsequent request with mismatched MCP-Protocol-Version is rejected") + func mismatchedProtocolVersionHeaderRejected() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let consumer = StubExchangeConsumer() + let store = MCPSessionStore() + let progressSink = NullProgressSink() + let dispatcher = MCPProtocolDispatcher( + handlers: [InitializeHandler(), PingHandler()], + sessionStore: store, + progressSink: progressSink + ) + await consumer.start(transport: transport) { exchange in + await dispatcher.dispatch(exchange) + } + defer { Task { await consumer.stop() } } + + let initializeRequest = JsonRpcRequest( + id: .number(1), + method: "initialize", + params: .object(["protocolVersion": .string(InitializeHandler.supportedProtocolVersion)]) + ) + let initBody = try JsonRpcCodec.encode(.request(initializeRequest)) + let (_, initResponse) = try await URLSession.shared.data(for: makePost(port: port, body: initBody)) + let initHttp = try #require(initResponse as? HTTPURLResponse) + let sessionId = try #require(initHttp.value(forHTTPHeaderField: "Mcp-Session-Id")) + + let initialized = JsonRpcNotification(method: "notifications/initialized", params: nil) + let initializedBody = try JsonRpcCodec.encode(.notification(initialized)) + var initializedRequest = makePost(port: port, body: initializedBody, sessionId: sessionId) + _ = try await URLSession.shared.data(for: initializedRequest) + _ = initializedRequest + + let pingRequest = JsonRpcRequest(id: .number(2), method: "ping", params: nil) + let pingBody = try JsonRpcCodec.encode(.request(pingRequest)) + guard let url = URL(string: "http://127.0.0.1:\(port)/mcp") else { + Issue.record("Failed to construct URL") + return + } + var mismatched = URLRequest(url: url) + mismatched.httpMethod = "POST" + mismatched.httpBody = pingBody + mismatched.setValue("application/json", forHTTPHeaderField: "Content-Type") + mismatched.setValue("1999-01-01", forHTTPHeaderField: "mcp-protocol-version") + mismatched.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") + mismatched.setValue("Bearer test-token", forHTTPHeaderField: "Authorization") + let (data, response) = try await URLSession.shared.data(for: mismatched) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 400) + let parsed = try parseJsonRpcError(data) + #expect(parsed.code == JsonRpcErrorCode.invalidRequest) + } + @Test("GET /mcp opens an SSE stream that delivers server-sent notifications") func getMcpStreamsServerNotifications() async throws { let auth = StubAlwaysAllowAuthenticator() From 7e48168ce4ab6e95a2525e936c5cdd4c55237017 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:01:49 +0700 Subject: [PATCH 34/54] test(mcp): assert Mcp-Session-Id header lookup is case-insensitive --- .../Core/MCP/Wire/HttpRequestParserTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift b/TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift index 01666d9a9..435646df8 100644 --- a/TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift +++ b/TableProTests/Core/MCP/Wire/HttpRequestParserTests.swift @@ -29,6 +29,24 @@ final class HttpRequestParserTests: XCTestCase { XCTAssertEqual(head.headers.value(for: "CONTENT-TYPE"), "text/plain") } + func testMcpSessionIdLookupCaseInsensitive() throws { + let lowercaseRaw = "GET / HTTP/1.1\r\nmcp-session-id: abc-123\r\n\r\n" + let lowercaseResult = try HttpRequestParser.parse(Data(lowercaseRaw.utf8)) + guard case .complete(let lowerHead, _, _) = lowercaseResult else { + XCTFail("Expected complete for lowercase") + return + } + XCTAssertEqual(lowerHead.headers.value(for: "Mcp-Session-Id"), "abc-123") + + let uppercaseRaw = "GET / HTTP/1.1\r\nMCP-SESSION-ID: xyz-789\r\n\r\n" + let uppercaseResult = try HttpRequestParser.parse(Data(uppercaseRaw.utf8)) + guard case .complete(let upperHead, _, _) = uppercaseResult else { + XCTFail("Expected complete for uppercase") + return + } + XCTAssertEqual(upperHead.headers.value(for: "Mcp-Session-Id"), "xyz-789") + } + func testParsesPostBodyOfExactContentLength() throws { let body = "{\"x\":1}" let raw = "POST /rpc HTTP/1.1\r\nHost: x\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)" From 7dbe3635424d863252ff84e3043113d25a2869fa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:03:56 +0700 Subject: [PATCH 35/54] fix(mcp): reflect Origin header against allowlist instead of hardcoding localhost --- .../Core/MCP/Transport/MCPCorsHeaders.swift | 38 +++++++++++------- .../Transport/MCPHttpServerTransport.swift | 23 ++++++++--- .../MCPHttpServerTransportTests.swift | 39 +++++++++++++++++-- 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/TablePro/Core/MCP/Transport/MCPCorsHeaders.swift b/TablePro/Core/MCP/Transport/MCPCorsHeaders.swift index a6d136f56..2ca17cf1d 100644 --- a/TablePro/Core/MCP/Transport/MCPCorsHeaders.swift +++ b/TablePro/Core/MCP/Transport/MCPCorsHeaders.swift @@ -1,8 +1,14 @@ import Foundation public enum MCPCorsHeaders { - public static let standard: [(String, String)] = [ - ("Access-Control-Allow-Origin", "http://localhost"), + private static let allowedHosts: Set = [ + "localhost", + "127.0.0.1", + "claude.ai", + "app.cursor.com" + ] + + private static let baseHeaders: [(String, String)] = [ ("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"), ( "Access-Control-Allow-Headers", @@ -12,16 +18,22 @@ public enum MCPCorsHeaders { ("Access-Control-Max-Age", "86400") ] - public static func corsHeaders(allowingOrigin origin: String) -> [(String, String)] { - [ - ("Access-Control-Allow-Origin", origin), - ("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"), - ( - "Access-Control-Allow-Headers", - "Content-Type, Mcp-Session-Id, mcp-protocol-version, Authorization, Last-Event-ID" - ), - ("Access-Control-Expose-Headers", "Mcp-Session-Id"), - ("Access-Control-Max-Age", "86400") - ] + public static func headers(forOrigin origin: String?) -> [(String, String)] { + guard let origin, !origin.isEmpty else { return [] } + guard isAllowed(origin: origin) else { return [] } + var headers: [(String, String)] = [("Access-Control-Allow-Origin", origin)] + headers.append(("Vary", "Origin")) + headers.append(contentsOf: baseHeaders) + return headers + } + + public static func isAllowed(origin: String) -> Bool { + guard let url = URL(string: origin), + let scheme = url.scheme?.lowercased(), + let host = url.host?.lowercased() else { + return false + } + guard scheme == "http" || scheme == "https" else { return false } + return allowedHosts.contains(host) } } diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift index 24459289e..39ea1d0fa 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -286,6 +286,8 @@ public actor MCPHttpServerTransport { let clientAddress: MCPClientAddress = await context.clientAddress() let now = await clock.now() + await context.setOrigin(head.headers.value(for: "Origin")) + if head.method == .post, stripQueryString(head.path) == "/v1/integrations/exchange" { await handleIntegrationsExchange(body: body, context: context) return @@ -757,12 +759,21 @@ actor HttpConnectionContext { private var requestComplete = false private var cancelled = false private var sseActive = false + private var origin: String? init(id: UUID, connection: NWConnection) { self.id = id self.connection = connection } + func setOrigin(_ value: String?) { + origin = value + } + + private func corsHeaders() -> [(String, String)] { + MCPCorsHeaders.headers(forOrigin: origin) + } + func start( onData: @escaping @Sendable (Data) async -> Void, onClosed: @escaping @Sendable () async -> Void @@ -878,7 +889,7 @@ actor HttpConnectionContext { headers.append(("Mcp-Session-Id", sessionId.rawValue)) } headers.append(contentsOf: extraHeaders) - headers.append(contentsOf: MCPCorsHeaders.standard) + headers.append(contentsOf: self.corsHeaders()) let head = HttpResponseHead(status: status, headers: HttpHeaders(headers)) let payload = HttpResponseEncoder.encode(head, body: data) await send(payload) @@ -890,7 +901,7 @@ actor HttpConnectionContext { ("Content-Type", "application/json"), ("Connection", "close") ] - headers.append(contentsOf: MCPCorsHeaders.standard) + headers.append(contentsOf: self.corsHeaders()) let head = HttpResponseHead(status: status, headers: HttpHeaders(headers)) let payload = HttpResponseEncoder.encode(head, body: body) await send(payload) @@ -905,7 +916,7 @@ actor HttpConnectionContext { func writeOptions204() async { if cancelled { return } var headers: [(String, String)] = [("Connection", "close")] - headers.append(contentsOf: MCPCorsHeaders.standard) + headers.append(contentsOf: self.corsHeaders()) let head = HttpResponseHead(status: .noContent, headers: HttpHeaders(headers)) let payload = HttpResponseEncoder.encode(head, body: nil) await send(payload) @@ -914,7 +925,7 @@ actor HttpConnectionContext { func writeNoContent() async { if cancelled { return } var headers: [(String, String)] = [("Connection", "close")] - headers.append(contentsOf: MCPCorsHeaders.standard) + headers.append(contentsOf: self.corsHeaders()) let head = HttpResponseHead(status: .noContent, headers: HttpHeaders(headers)) let payload = HttpResponseEncoder.encode(head, body: nil) await send(payload) @@ -923,7 +934,7 @@ actor HttpConnectionContext { func writeAccepted() async { if cancelled { return } var headers: [(String, String)] = [("Connection", "close")] - headers.append(contentsOf: MCPCorsHeaders.standard) + headers.append(contentsOf: self.corsHeaders()) let head = HttpResponseHead(status: .accepted, headers: HttpHeaders(headers)) let payload = HttpResponseEncoder.encode(head, body: nil) await send(payload) @@ -938,7 +949,7 @@ actor HttpConnectionContext { ("Connection", "keep-alive"), ("Mcp-Session-Id", sessionId.rawValue) ] - headers.append(contentsOf: MCPCorsHeaders.standard) + headers.append(contentsOf: self.corsHeaders()) let head = HttpResponseHead(status: .ok, headers: HttpHeaders(headers)) let payload = HttpResponseEncoder.encode(head, body: nil) await send(payload) diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift index 746b4a2f0..ab519c88a 100644 --- a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift @@ -86,13 +86,16 @@ struct MCPHttpServerTransportTests { return request } - private func makeOptions(port: UInt16) -> URLRequest { + private func makeOptions(port: UInt16, origin: String? = "http://localhost") -> URLRequest { guard let url = URL(string: "http://127.0.0.1:\(port)/mcp") else { fatalError("Failed to construct test URL") } var request = URLRequest(url: url) request.httpMethod = "OPTIONS" request.setValue("Bearer test-token", forHTTPHeaderField: "Authorization") + if let origin { + request.setValue(origin, forHTTPHeaderField: "Origin") + } return request } @@ -355,23 +358,51 @@ struct MCPHttpServerTransportTests { #expect(parsed.code == JsonRpcErrorCode.methodNotFound) } - @Test("OPTIONS request returns 204 with CORS headers") + @Test("OPTIONS request returns 204 with CORS headers reflecting allowed origin") func optionsReturnsNoContent() async throws { let auth = StubAlwaysAllowAuthenticator() let (transport, _, port) = try await startedTransport(authenticator: auth) defer { Task { await transport.stop() } } - let request = makeOptions(port: port) + let request = makeOptions(port: port, origin: "http://localhost") let (_, response) = try await URLSession.shared.data(for: request) let http = try #require(response as? HTTPURLResponse) #expect(http.statusCode == 204) let allowOrigin = http.value(forHTTPHeaderField: "Access-Control-Allow-Origin") - #expect(allowOrigin != nil) + #expect(allowOrigin == "http://localhost") let allowHeaders = http.value(forHTTPHeaderField: "Access-Control-Allow-Headers") #expect(allowHeaders?.contains("Last-Event-ID") == true) } + @Test("OPTIONS request from disallowed origin omits CORS headers") + func optionsDisallowedOriginOmitsCors() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let request = makeOptions(port: port, origin: "https://evil.example.com") + let (_, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 204) + #expect(http.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == nil) + } + + @Test("OPTIONS request without Origin header omits CORS headers") + func optionsWithoutOriginOmitsCors() async throws { + let auth = StubAlwaysAllowAuthenticator() + let (transport, _, port) = try await startedTransport(authenticator: auth) + defer { Task { await transport.stop() } } + + let request = makeOptions(port: port, origin: nil) + let (_, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 204) + #expect(http.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == nil) + } + @Test("Initialize with unsupported protocolVersion returns invalid_request error") func initializeRejectsUnsupportedProtocolVersion() async throws { let auth = StubAlwaysAllowAuthenticator() From d0360dd4193ae62523c3d60595e30bc51f380168 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:09:02 +0700 Subject: [PATCH 36/54] fix(mcp): cancel in-flight requests and terminate sessions on token revoke --- .../Auth/MCPBearerTokenAuthenticator.swift | 12 ++++- TablePro/Core/MCP/Auth/MCPPrincipal.swift | 11 +++- TablePro/Core/MCP/MCPServerManager.swift | 50 +++++++++++++++++++ TablePro/Core/MCP/MCPTokenStore.swift | 23 +++++++++ .../MCP/Protocol/MCPInflightRegistry.swift | 32 ++++++++++-- .../MCP/Protocol/MCPProtocolDispatcher.swift | 12 ++++- TablePro/Core/MCP/Session/MCPSession.swift | 7 +++ .../Core/MCP/Session/MCPSessionStore.swift | 11 ++++ .../Core/MCP/MCPTokenStoreTests.swift | 36 +++++++++++++ .../Protocol/MCPInflightRegistryTests.swift | 42 ++++++++++++++++ 10 files changed, 228 insertions(+), 8 deletions(-) diff --git a/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift index 0e41fabc9..3a6993210 100644 --- a/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift +++ b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift @@ -3,12 +3,20 @@ import Foundation import os public struct MCPValidatedToken: Sendable, Equatable { + public let tokenId: UUID public let label: String? public let scopes: Set public let issuedAt: Date public let expiresAt: Date? - public init(label: String?, scopes: Set, issuedAt: Date, expiresAt: Date?) { + public init( + tokenId: UUID, + label: String?, + scopes: Set, + issuedAt: Date, + expiresAt: Date? + ) { + self.tokenId = tokenId self.label = label self.scopes = scopes self.issuedAt = issuedAt @@ -40,6 +48,7 @@ internal extension MCPTokenStore { return .failure(.revoked) } let validated = MCPValidatedToken( + tokenId: authToken.id, label: authToken.name, scopes: Self.mcpScopes(for: authToken.permissions), issuedAt: authToken.createdAt, @@ -142,6 +151,7 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { _ = await rateLimiter.recordAttempt(key: principalKey, success: true) let principal = MCPPrincipal( tokenFingerprint: fingerprint, + tokenId: validated.tokenId, scopes: validated.scopes, metadata: MCPPrincipalMetadata( label: validated.label, diff --git a/TablePro/Core/MCP/Auth/MCPPrincipal.swift b/TablePro/Core/MCP/Auth/MCPPrincipal.swift index 1efe5d87d..d4de96724 100644 --- a/TablePro/Core/MCP/Auth/MCPPrincipal.swift +++ b/TablePro/Core/MCP/Auth/MCPPrincipal.swift @@ -21,22 +21,31 @@ public struct MCPPrincipalMetadata: Sendable, Equatable { public struct MCPPrincipal: Sendable, Equatable, Hashable { public let tokenFingerprint: String + public let tokenId: UUID? public let scopes: Set public let metadata: MCPPrincipalMetadata - public init(tokenFingerprint: String, scopes: Set, metadata: MCPPrincipalMetadata) { + public init( + tokenFingerprint: String, + tokenId: UUID? = nil, + scopes: Set, + metadata: MCPPrincipalMetadata + ) { self.tokenFingerprint = tokenFingerprint + self.tokenId = tokenId self.scopes = scopes self.metadata = metadata } public static func == (lhs: MCPPrincipal, rhs: MCPPrincipal) -> Bool { lhs.tokenFingerprint == rhs.tokenFingerprint + && lhs.tokenId == rhs.tokenId && lhs.scopes == rhs.scopes && lhs.metadata == rhs.metadata } public func hash(into hasher: inout Hasher) { hasher.combine(tokenFingerprint) + hasher.combine(tokenId) } } diff --git a/TablePro/Core/MCP/MCPServerManager.swift b/TablePro/Core/MCP/MCPServerManager.swift index 69f93b8b8..5af1031b5 100644 --- a/TablePro/Core/MCP/MCPServerManager.swift +++ b/TablePro/Core/MCP/MCPServerManager.swift @@ -41,6 +41,7 @@ final class MCPServerManager { private var bridgeTokenId: UUID? private var internalBridgeToken: String? private var serverGeneration: Int = 0 + private var revocationObserverId: UUID? var isRunning: Bool { if case .running = state { return true } else { return false } @@ -136,6 +137,12 @@ final class MCPServerManager { startDispatchLoop(transport: newTransport, dispatcher: newDispatcher, generation: generation) startStateLoop(transport: newTransport, generation: generation) startSessionEventsLoop(sessionStore: newSessionStore, generation: generation) + await registerRevocationObserver( + tokenStore: newTokenStore, + sessionStore: newSessionStore, + dispatcher: newDispatcher, + generation: generation + ) do { try await newTransport.start() @@ -250,6 +257,45 @@ final class MCPServerManager { serverGeneration == generation } + private func registerRevocationObserver( + tokenStore: MCPTokenStore, + sessionStore: MCPSessionStore, + dispatcher: MCPProtocolDispatcher, + generation: Int + ) async { + let observerId = await tokenStore.addRevocationObserver { [weak self] tokenIdString in + guard let tokenId = UUID(uuidString: tokenIdString) else { return } + guard let self else { return } + await self.handleTokenRevoked( + tokenId: tokenId, + sessionStore: sessionStore, + dispatcher: dispatcher, + generation: generation + ) + } + revocationObserverId = observerId + } + + private func handleTokenRevoked( + tokenId: UUID, + sessionStore: MCPSessionStore, + dispatcher: MCPProtocolDispatcher, + generation: Int + ) async { + guard isCurrentGeneration(generation) else { return } + let cancelledSessions = await dispatcher.cancelInflight(matchingTokenId: tokenId) + let extraSessions = await sessionStore.sessionIds(forPrincipalTokenId: tokenId) + let toTerminate = Set(cancelledSessions + extraSessions) + for sessionId in toTerminate { + await sessionStore.terminate(id: sessionId, reason: .clientRequested) + } + if !toTerminate.isEmpty { + Self.logger.info( + "Token \(tokenId.uuidString, privacy: .public) revoked: cancelled \(toTerminate.count, privacy: .public) session(s)" + ) + } + } + private func applyTransportState(_ transportState: MCPHttpServerState, generation: Int) { guard isCurrentGeneration(generation) else { return } switch transportState { @@ -293,6 +339,10 @@ final class MCPServerManager { rateLimiter = nil tlsManager = nil + if let observerId = revocationObserverId, let store = tokenStore { + await store.removeRevocationObserver(observerId) + revocationObserverId = nil + } await cleanupBridgeToken() tokenStore = nil connectedClients = [] diff --git a/TablePro/Core/MCP/MCPTokenStore.swift b/TablePro/Core/MCP/MCPTokenStore.swift index 48fbaeceb..f1b848848 100644 --- a/TablePro/Core/MCP/MCPTokenStore.swift +++ b/TablePro/Core/MCP/MCPTokenStore.swift @@ -153,6 +153,8 @@ actor MCPTokenStore { private var lastSavedAt: ContinuousClock.Instant = .now private static let saveCooldown: Duration = .seconds(60) + private var revocationObservers: [UUID: @Sendable (String) async -> Void] = [:] + init() { let appSupportUrl = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support") @@ -160,6 +162,17 @@ actor MCPTokenStore { self.storageUrl = directory.appendingPathComponent("mcp-tokens.json") } + @discardableResult + func addRevocationObserver(_ handler: @escaping @Sendable (String) async -> Void) -> UUID { + let id = UUID() + revocationObservers[id] = handler + return id + } + + func removeRevocationObserver(_ id: UUID) { + revocationObservers.removeValue(forKey: id) + } + func generate( name: String, permissions: TokenPermissions, @@ -226,6 +239,7 @@ actor MCPTokenStore { tokens[index].isActive = false save() + notifyRevocationObservers(tokenId: tokenId) let revokedName = tokens[index].name Self.logger.info("Revoked MCP token '\(revokedName, privacy: .public)'") @@ -241,10 +255,19 @@ actor MCPTokenStore { let name = tokens[index].name tokens.remove(at: index) save() + notifyRevocationObservers(tokenId: tokenId) Self.logger.info("Deleted MCP token '\(name, privacy: .public)'") } + private func notifyRevocationObservers(tokenId: UUID) { + let observers = Array(revocationObservers.values) + let key = tokenId.uuidString + for observer in observers { + Task { await observer(key) } + } + } + func list() -> [MCPAuthToken] { tokens } diff --git a/TablePro/Core/MCP/Protocol/MCPInflightRegistry.swift b/TablePro/Core/MCP/Protocol/MCPInflightRegistry.swift index 2e85ffa07..5d7bd0426 100644 --- a/TablePro/Core/MCP/Protocol/MCPInflightRegistry.swift +++ b/TablePro/Core/MCP/Protocol/MCPInflightRegistry.swift @@ -6,16 +6,38 @@ actor MCPInflightRegistry { let requestId: JsonRpcId } - private var entries: [Key: MCPCancellationToken] = [:] + private struct Entry { + let token: MCPCancellationToken + let tokenId: UUID? + } + + private var entries: [Key: Entry] = [:] - func register(requestId: JsonRpcId, sessionId: MCPSessionId, token: MCPCancellationToken) { - entries[Key(sessionId: sessionId, requestId: requestId)] = token + func register( + requestId: JsonRpcId, + sessionId: MCPSessionId, + token: MCPCancellationToken, + tokenId: UUID? = nil + ) { + entries[Key(sessionId: sessionId, requestId: requestId)] = Entry( + token: token, + tokenId: tokenId + ) } func cancel(requestId: JsonRpcId, sessionId: MCPSessionId) async { let key = Key(sessionId: sessionId, requestId: requestId) - guard let token = entries.removeValue(forKey: key) else { return } - await token.cancel() + guard let entry = entries.removeValue(forKey: key) else { return } + await entry.token.cancel() + } + + func cancelAll(matchingTokenId tokenId: UUID) async -> [MCPSessionId] { + let matching = entries.filter { $0.value.tokenId == tokenId } + for (key, entry) in matching { + await entry.token.cancel() + entries.removeValue(forKey: key) + } + return Array(Set(matching.map { $0.key.sessionId })) } func remove(requestId: JsonRpcId, sessionId: MCPSessionId) { diff --git a/TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift b/TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift index 4c4632107..3091f3aae 100644 --- a/TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift +++ b/TablePro/Core/MCP/Protocol/MCPProtocolDispatcher.swift @@ -43,6 +43,10 @@ public actor MCPProtocolDispatcher { await inflight.cancel(requestId: requestId, sessionId: sessionId) } + public func cancelInflight(matchingTokenId tokenId: UUID) async -> [MCPSessionId] { + await inflight.cancelAll(matchingTokenId: tokenId) + } + private func handleRequest(_ request: JsonRpcRequest, exchange: MCPInboundExchange) async { guard let handler = handlers[request.method] else { await respondError( @@ -90,9 +94,15 @@ public actor MCPProtocolDispatcher { } await session.touch(now: await clock.now()) + await session.bindPrincipal(tokenId: principal.tokenId) let token = MCPCancellationToken() - await inflight.register(requestId: request.id, sessionId: session.id, token: token) + await inflight.register( + requestId: request.id, + sessionId: session.id, + token: token, + tokenId: principal.tokenId + ) let progressToken = MCPProgressEmitter.extractProgressToken(from: request.params) let emitter = MCPProgressEmitter( diff --git a/TablePro/Core/MCP/Session/MCPSession.swift b/TablePro/Core/MCP/Session/MCPSession.swift index 090a8105b..dc4c01cdd 100644 --- a/TablePro/Core/MCP/Session/MCPSession.swift +++ b/TablePro/Core/MCP/Session/MCPSession.swift @@ -44,6 +44,7 @@ public actor MCPSession { public private(set) var clientInfo: MCPClientInfo? public private(set) var negotiatedProtocolVersion: String? public private(set) var clientCapabilities: JsonValue? + public private(set) var principalTokenId: UUID? public init(id: MCPSessionId = .generate(), now: Date = Date()) { self.id = id @@ -53,6 +54,7 @@ public actor MCPSession { self.clientInfo = nil self.negotiatedProtocolVersion = nil self.clientCapabilities = nil + self.principalTokenId = nil } public func touch(now: Date = Date()) { @@ -60,6 +62,11 @@ public actor MCPSession { lastActivityAt = now } + public func bindPrincipal(tokenId: UUID?) { + guard !isTerminated else { return } + principalTokenId = tokenId + } + public func recordInitialize( clientInfo: MCPClientInfo, protocolVersion: String, diff --git a/TablePro/Core/MCP/Session/MCPSessionStore.swift b/TablePro/Core/MCP/Session/MCPSessionStore.swift index bc4bc1c71..7adf2e6b4 100644 --- a/TablePro/Core/MCP/Session/MCPSessionStore.swift +++ b/TablePro/Core/MCP/Session/MCPSessionStore.swift @@ -62,6 +62,17 @@ public actor MCPSessionStore { Array(sessions.values) } + public func sessionIds(forPrincipalTokenId tokenId: UUID) async -> [MCPSessionId] { + var matching: [MCPSessionId] = [] + for (sessionId, session) in sessions { + let bound = await session.principalTokenId + if bound == tokenId { + matching.append(sessionId) + } + } + return matching + } + public var events: AsyncStream { let (stream, continuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(64) diff --git a/TableProTests/Core/MCP/MCPTokenStoreTests.swift b/TableProTests/Core/MCP/MCPTokenStoreTests.swift index 04abdf6cd..e3cd12b3b 100644 --- a/TableProTests/Core/MCP/MCPTokenStoreTests.swift +++ b/TableProTests/Core/MCP/MCPTokenStoreTests.swift @@ -289,6 +289,26 @@ struct MCPTokenStoreTests { #expect(revokedToken.isActive == false) } + @Test("revoke fires registered revocation observers with token id") + func revokeNotifiesObservers() async { + let store = makeStore() + let result = await store.generate(name: "observed", permissions: .readOnly) + + let receivedBox = Lock(value: [String]()) + let observed = receivedBox + await store.addRevocationObserver { tokenIdString in + await observed.append(tokenIdString) + } + + await store.revoke(tokenId: result.token.id) + try? await Task.sleep(for: .milliseconds(50)) + await store.delete(tokenId: result.token.id) + try? await Task.sleep(for: .milliseconds(50)) + + let received = await receivedBox.snapshot() + #expect(received.contains(result.token.id.uuidString)) + } + @Test("delete removes token from list") func deleteRemovesTokenFromList() async { let store = makeStore() @@ -371,3 +391,19 @@ struct MCPTokenStoreTests { #expect(result1.plaintext != result2.plaintext) } } + +private actor Lock: Sendable { + private var value: Value + + init(value: Value) { + self.value = value + } + + func append(_ element: T) where Value == [T] { + value.append(element) + } + + func snapshot() -> Value { + value + } +} diff --git a/TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift b/TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift index 98cb1fcd7..1373b13e5 100644 --- a/TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift +++ b/TableProTests/Core/MCP/Protocol/MCPInflightRegistryTests.swift @@ -82,6 +82,48 @@ final class MCPInflightRegistryTests: XCTestCase { XCTAssertFalse(cancelledB) } + func testCancelAllMatchingTokenIdCancelsOnlyMatching() async { + let registry = MCPInflightRegistry() + let tokenA = MCPCancellationToken() + let tokenB = MCPCancellationToken() + let tokenC = MCPCancellationToken() + let session = MCPSessionId("session-revoked") + let revokedTokenId = UUID() + let otherTokenId = UUID() + + await registry.register( + requestId: .number(1), + sessionId: session, + token: tokenA, + tokenId: revokedTokenId + ) + await registry.register( + requestId: .number(2), + sessionId: session, + token: tokenB, + tokenId: revokedTokenId + ) + await registry.register( + requestId: .number(3), + sessionId: session, + token: tokenC, + tokenId: otherTokenId + ) + + let cancelledSessions = await registry.cancelAll(matchingTokenId: revokedTokenId) + XCTAssertEqual(cancelledSessions, [session]) + + let cancelledA = await tokenA.isCancelled() + let cancelledB = await tokenB.isCancelled() + let cancelledC = await tokenC.isCancelled() + XCTAssertTrue(cancelledA) + XCTAssertTrue(cancelledB) + XCTAssertFalse(cancelledC) + + let count = await registry.count() + XCTAssertEqual(count, 1) + } + func testCountReflectsActiveRegistrations() async { let registry = MCPInflightRegistry() let session = MCPSessionId("session-count") From 2ea92228109e93a939d67843f51c93ba285406f4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:11:25 +0700 Subject: [PATCH 37/54] fix(mcp): emit Retry-After header on 429 with actual lockout duration --- TablePro/Core/MCP/Auth/MCPAuthDecision.swift | 14 ++++-- .../Auth/MCPBearerTokenAuthenticator.swift | 46 +++++++++++++------ .../Core/MCP/RateLimit/MCPRateLimiter.swift | 6 +++ .../Transport/MCPHttpServerTransport.swift | 2 +- .../Core/MCP/Transport/MCPProtocolError.swift | 11 +++-- .../MCP/Helpers/MCPTransportTestStubs.swift | 2 +- .../MCPHttpServerTransportTests.swift | 4 +- 7 files changed, 63 insertions(+), 22 deletions(-) diff --git a/TablePro/Core/MCP/Auth/MCPAuthDecision.swift b/TablePro/Core/MCP/Auth/MCPAuthDecision.swift index faa79a8ea..1d4833afd 100644 --- a/TablePro/Core/MCP/Auth/MCPAuthDecision.swift +++ b/TablePro/Core/MCP/Auth/MCPAuthDecision.swift @@ -9,11 +9,18 @@ public struct MCPAuthDenialReason: Sendable, Equatable { public let httpStatus: Int public let challenge: String? public let logMessage: String + public let retryAfterSeconds: Int? - public init(httpStatus: Int, challenge: String?, logMessage: String) { + public init( + httpStatus: Int, + challenge: String?, + logMessage: String, + retryAfterSeconds: Int? = nil + ) { self.httpStatus = httpStatus self.challenge = challenge self.logMessage = logMessage + self.retryAfterSeconds = retryAfterSeconds } public static func unauthenticated(reason: String) -> Self { @@ -48,11 +55,12 @@ public struct MCPAuthDenialReason: Sendable, Equatable { ) } - public static func rateLimited() -> Self { + public static func rateLimited(retryAfterSeconds: Int? = nil) -> Self { Self( httpStatus: 429, challenge: nil, - logMessage: "rate_limited" + logMessage: "rate_limited", + retryAfterSeconds: retryAfterSeconds ) } } diff --git a/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift index 3a6993210..f8d159eb9 100644 --- a/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift +++ b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift @@ -74,10 +74,16 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { private let tokenStore: any MCPTokenStoreProtocol private let rateLimiter: MCPRateLimiter + private let clock: any MCPClock - public init(tokenStore: any MCPTokenStoreProtocol, rateLimiter: MCPRateLimiter) { + public init( + tokenStore: any MCPTokenStoreProtocol, + rateLimiter: MCPRateLimiter, + clock: any MCPClock = MCPSystemClock() + ) { self.tokenStore = tokenStore self.rateLimiter = rateLimiter + self.clock = clock } public func authenticate( @@ -88,10 +94,10 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { guard let header = authorizationHeader, !header.isEmpty else { let key = MCPRateLimitKey(clientAddress: clientAddress, principalFingerprint: nil) - if await rateLimiter.isLocked(key: key) { + if let retry = await rateLimitedRetryAfter(key: key) { Self.logger.warning("Auth rejected (rate limited, missing header)") - MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: 0) - return .deny(.rateLimited()) + MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: retry) + return .deny(.rateLimited(retryAfterSeconds: retry)) } Self.logger.info("Auth missing Authorization header") MCPAuditLogger.logAuthFailure(reason: "missing_authorization_header", ip: ipString) @@ -100,9 +106,9 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { guard let token = Self.parseBearerToken(header) else { let key = MCPRateLimitKey(clientAddress: clientAddress, principalFingerprint: nil) - if await rateLimiter.isLocked(key: key) { - MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: 0) - return .deny(.rateLimited()) + if let retry = await rateLimitedRetryAfter(key: key) { + MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: retry) + return .deny(.rateLimited(retryAfterSeconds: retry)) } _ = await rateLimiter.recordAttempt(key: key, success: false) Self.logger.info("Auth invalid Authorization scheme") @@ -116,21 +122,22 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { principalFingerprint: fingerprint ) - if await rateLimiter.isLocked(key: principalKey) { + if let retry = await rateLimitedRetryAfter(key: principalKey) { Self.logger.warning( "Auth rate limited fingerprint=\(fingerprint, privacy: .public)" ) - MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: 0) - return .deny(.rateLimited()) + MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: retry) + return .deny(.rateLimited(retryAfterSeconds: retry)) } let validation = await tokenStore.validateBearerToken(token) switch validation { case .failure(let error): let verdict = await rateLimiter.recordAttempt(key: principalKey, success: false) - if case .lockedUntil = verdict { - MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: 0) - return .deny(.rateLimited()) + if case .lockedUntil(let unlockDate) = verdict { + let retry = await retryAfter(unlockDate: unlockDate) + MCPAuditLogger.logRateLimited(ip: ipString, retryAfterSeconds: retry) + return .deny(.rateLimited(retryAfterSeconds: retry)) } switch error { case .unknownToken: @@ -165,6 +172,19 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { } } + private func rateLimitedRetryAfter(key: MCPRateLimitKey) async -> Int? { + guard await rateLimiter.isLocked(key: key) else { return nil } + guard let unlockDate = await rateLimiter.lockedUntil(key: key) else { return nil } + return await retryAfter(unlockDate: unlockDate) + } + + private func retryAfter(unlockDate: Date) async -> Int { + let now = await clock.now() + let delta = unlockDate.timeIntervalSince(now) + if delta <= 0 { return 1 } + return max(1, Int(delta.rounded(.up))) + } + private static func ipString(for address: MCPClientAddress) -> String { switch address { case .loopback: diff --git a/TablePro/Core/MCP/RateLimit/MCPRateLimiter.swift b/TablePro/Core/MCP/RateLimit/MCPRateLimiter.swift index 4ed826141..d3c2d53f3 100644 --- a/TablePro/Core/MCP/RateLimit/MCPRateLimiter.swift +++ b/TablePro/Core/MCP/RateLimit/MCPRateLimiter.swift @@ -88,6 +88,12 @@ public actor MCPRateLimiter { return lockedUntil > (await clock.now()) } + public func lockedUntil(key: MCPRateLimitKey) async -> Date? { + guard let lockedUntil = buckets[key]?.lockedUntil else { return nil } + guard lockedUntil > (await clock.now()) else { return nil } + return lockedUntil + } + public func reset(key: MCPRateLimitKey) async { buckets.removeValue(forKey: key) } diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift index 39ea1d0fa..c44334017 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -658,7 +658,7 @@ public actor MCPHttpServerTransport { case 403: return .forbidden(reason: reason.logMessage) case 429: - return .rateLimited() + return .rateLimited(retryAfterSeconds: reason.retryAfterSeconds) default: return MCPProtocolError( code: JsonRpcErrorCode.serverError, diff --git a/TablePro/Core/MCP/Transport/MCPProtocolError.swift b/TablePro/Core/MCP/Transport/MCPProtocolError.swift index b5e3a9713..d28091126 100644 --- a/TablePro/Core/MCP/Transport/MCPProtocolError.swift +++ b/TablePro/Core/MCP/Transport/MCPProtocolError.swift @@ -110,11 +110,16 @@ public extension MCPProtocolError { ) } - static func rateLimited() -> Self { - Self( + static func rateLimited(retryAfterSeconds: Int? = nil) -> Self { + var headers: [(String, String)] = [] + if let retryAfterSeconds, retryAfterSeconds > 0 { + headers.append(("Retry-After", String(retryAfterSeconds))) + } + return Self( code: JsonRpcErrorCode.serverError, message: "Rate limited", - httpStatus: .tooManyRequests + httpStatus: .tooManyRequests, + extraHeaders: headers ) } diff --git a/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift b/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift index caabf8e0b..043b4804d 100644 --- a/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift +++ b/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift @@ -50,7 +50,7 @@ actor StubBearerAuthenticator: MCPAuthenticator { ) async -> MCPAuthDecision { let attempts = attemptsByAddress[clientAddress] ?? 0 if attempts >= maxAttempts { - return .deny(.rateLimited()) + return .deny(.rateLimited(retryAfterSeconds: 30)) } guard let raw = authorizationHeader, !raw.isEmpty else { diff --git a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift index ab519c88a..f4ad3d169 100644 --- a/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift @@ -268,7 +268,7 @@ struct MCPHttpServerTransportTests { #expect(parsed.code != 0) } - @Test("Rate limit kicks in after repeated bad attempts") + @Test("Rate limit kicks in after repeated bad attempts and includes Retry-After") func rateLimitAfterBadAttempts() async throws { let auth = StubBearerAuthenticator(validToken: "valid", maxAttempts: 3) let (transport, _, port) = try await startedTransport(authenticator: auth) @@ -290,6 +290,8 @@ struct MCPHttpServerTransportTests { let http = try #require(response as? HTTPURLResponse) #expect(http.statusCode == 429) + let retryAfter = http.value(forHTTPHeaderField: "Retry-After") + #expect(retryAfter == "30") let parsed = try parseJsonRpcError(data) #expect(parsed.code != 0) } From 31c122ad92558f4ddcded97676f440b3ee5fc3a8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:11:56 +0700 Subject: [PATCH 38/54] chore(mcp): skip app delegate startup under XCTest to avoid orphan host processes --- TablePro/AppDelegate.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index bf3f1c360..e4992c104 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -31,6 +31,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Lifecycle func applicationDidFinishLaunching(_ notification: Notification) { + if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { + Self.logger.info("Running under XCTest, skipping normal app startup") + return + } + let appearanceSettings = AppSettingsManager.shared.appearance ThemeEngine.shared.updateAppearanceAndTheme( mode: appearanceSettings.appearanceMode, From 6496186ce5e6355ccd60b02221af7bbfa993657a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:13:43 +0700 Subject: [PATCH 39/54] fix(mcp): clear stale handshake file with dead PID before writing new one --- TablePro/Core/MCP/MCPServerManager.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/MCP/MCPServerManager.swift b/TablePro/Core/MCP/MCPServerManager.swift index 5af1031b5..6ab039793 100644 --- a/TablePro/Core/MCP/MCPServerManager.swift +++ b/TablePro/Core/MCP/MCPServerManager.swift @@ -61,6 +61,8 @@ final class MCPServerManager { await stop() } + Self.removeStaleHandshakeFileIfNeeded() + serverGeneration += 1 let generation = serverGeneration state = .starting @@ -393,7 +395,7 @@ final class MCPServerManager { "\(handshakeDirectoryPath)/mcp-handshake.json" }() - private struct HandshakeFilePayload: Encodable { + private struct HandshakeFilePayload: Codable { let port: Int let token: String let pid: Int32 @@ -446,6 +448,18 @@ final class MCPServerManager { } } + private static func removeStaleHandshakeFileIfNeeded() { + let path = handshakeFilePath + guard FileManager.default.fileExists(atPath: path) else { return } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return } + guard let payload = try? JSONDecoder().decode(HandshakeFilePayload.self, from: data) else { return } + let currentPid = ProcessInfo.processInfo.processIdentifier + if payload.pid == currentPid { return } + if kill(payload.pid, 0) == 0 { return } + try? FileManager.default.removeItem(atPath: path) + Self.logger.info("Removed stale MCP handshake from PID \(payload.pid, privacy: .public)") + } + private func deleteHandshakeFile() { let fileManager = FileManager.default guard fileManager.fileExists(atPath: Self.handshakeFilePath) else { return } From ca4a7e6f1e3359ad4ea454a4a18fd4cd439afa56 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:14:57 +0700 Subject: [PATCH 40/54] fix(mcp): reject duplicate initialize on the same session --- .../Protocol/Handlers/InitializeHandler.swift | 8 +++++ .../Handlers/InitializeHandlerTests.swift | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift index 1641c1ddc..ec69d17b7 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift @@ -14,6 +14,14 @@ public struct InitializeHandler: MCPMethodHandler { public init() {} public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { + let sessionState = await context.session.state + if case .ready = sessionState { + throw MCPProtocolError.invalidRequest(detail: "Session already initialized") + } + if await context.session.clientInfo != nil { + throw MCPProtocolError.invalidRequest(detail: "initialize already received for this session") + } + let requestedVersion = params?["protocolVersion"]?.stringValue let negotiatedVersion = Self.negotiate(requestedVersion: requestedVersion) guard let protocolVersion = negotiatedVersion else { diff --git a/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift index 1bd368782..da6f80223 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift @@ -94,6 +94,40 @@ final class InitializeHandlerTests: XCTestCase { XCTAssertNil(info?.version) } + func testRejectsRepeatedInitializeOnSameSession() async throws { + let context = try await makeContext() + let handler = InitializeHandler() + let params: JsonValue = .object([ + "protocolVersion": .string("2025-03-26"), + "clientInfo": .object(["name": .string("first")]) + ]) + + _ = try await handler.handle(params: params, context: context) + + do { + _ = try await handler.handle(params: params, context: context) + XCTFail("Expected handler to throw on second initialize") + } catch let error as MCPProtocolError { + XCTAssertEqual(error.code, JsonRpcErrorCode.invalidRequest) + } + } + + func testRejectsUnsupportedProtocolVersion() async throws { + let context = try await makeContext() + let handler = InitializeHandler() + let params: JsonValue = .object([ + "protocolVersion": .string("1999-01-01"), + "clientInfo": .object(["name": .string("vintage")]) + ]) + + do { + _ = try await handler.handle(params: params, context: context) + XCTFail("Expected handler to throw on unsupported protocolVersion") + } catch let error as MCPProtocolError { + XCTAssertEqual(error.code, JsonRpcErrorCode.invalidRequest) + } + } + func testMissingProtocolVersionFallsBackToSupported() async throws { let context = try await makeContext() let handler = InitializeHandler() From 0427bb437ec37a71a5b747c3ec976ff8828f5e29 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:15:55 +0700 Subject: [PATCH 41/54] feat(mcp): broadcast audit log inserts so the activity panel auto-refreshes --- TablePro/Core/MCP/MCPAuditLogStorage.swift | 12 +++++++++++- .../Views/Settings/Sections/MCPAuditLogView.swift | 6 ++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/MCP/MCPAuditLogStorage.swift b/TablePro/Core/MCP/MCPAuditLogStorage.swift index c42e4e7c0..02fe1cef5 100644 --- a/TablePro/Core/MCP/MCPAuditLogStorage.swift +++ b/TablePro/Core/MCP/MCPAuditLogStorage.swift @@ -7,6 +7,10 @@ import Foundation import os import SQLite3 +extension Notification.Name { + static let mcpAuditLogChanged = Notification.Name("com.TablePro.mcp.auditLogChanged") +} + actor MCPAuditLogStorage { static let shared = MCPAuditLogStorage() private static let logger = Logger(subsystem: "com.TablePro", category: "MCPAuditLogStorage") @@ -158,7 +162,13 @@ actor MCPAuditLogStorage { sqlite3_bind_null(statement, 9) } - return sqlite3_step(statement) == SQLITE_DONE + let inserted = sqlite3_step(statement) == SQLITE_DONE + if inserted { + Task { @MainActor in + NotificationCenter.default.post(name: .mcpAuditLogChanged, object: nil) + } + } + return inserted } func query( diff --git a/TablePro/Views/Settings/Sections/MCPAuditLogView.swift b/TablePro/Views/Settings/Sections/MCPAuditLogView.swift index 8ebf5a824..b9425878c 100644 --- a/TablePro/Views/Settings/Sections/MCPAuditLogView.swift +++ b/TablePro/Views/Settings/Sections/MCPAuditLogView.swift @@ -12,6 +12,9 @@ struct MCPAuditLogView: View { @State private var searchText: String = "" @State private var isLoading = false + private let auditChanges = NotificationCenter.default + .publisher(for: .mcpAuditLogChanged) + var body: some View { VStack(alignment: .leading, spacing: 12) { searchBar @@ -36,6 +39,9 @@ struct MCPAuditLogView: View { } .padding() .task { await reload() } + .onReceive(auditChanges) { _ in + Task { await reload() } + } } private var searchBar: some View { From 564dbaccc3b91274ce086798108e27b81ebb66c9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:17:04 +0700 Subject: [PATCH 42/54] feat(mcp): emit error redirect when user denies the pairing approval sheet --- TablePro/Core/MCP/MCPPairingService.swift | 35 ++++++++++++++++++++++- docs/external-api/pairing.mdx | 11 +++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/MCP/MCPPairingService.swift b/TablePro/Core/MCP/MCPPairingService.swift index 78b01af2a..7f2fd010c 100644 --- a/TablePro/Core/MCP/MCPPairingService.swift +++ b/TablePro/Core/MCP/MCPPairingService.swift @@ -126,7 +126,20 @@ final class MCPPairingService { throw MCPDataLayerError.dataSourceError("Token store unavailable") } - let approval = try await AlertHelper.runPairingApproval(request: request) + let approval: PairingApproval + do { + approval = try await AlertHelper.runPairingApproval(request: request) + } catch let error as MCPDataLayerError where error.isUserCancelled { + Self.logger.info("Pairing denied for client '\(request.clientName, privacy: .public)'") + if let redirect = buildErrorRedirect( + base: request.redirectURL, + error: "denied", + description: "user_denied" + ) { + NSWorkspace.shared.open(redirect) + } + throw error + } let connectionAccess: ConnectionAccess = approval.allowedConnectionIds.map { .limited($0) } ?? .all let result = await tokenStore.generate( @@ -175,6 +188,26 @@ final class MCPPairingService { } } + private func buildErrorRedirect(base: URL, error: String, description: String) -> URL? { + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false) else { + return nil + } + var items = components.queryItems ?? [] + if base.scheme == "raycast" { + let payload: [String: String] = ["error": error, "error_description": description] + guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) else { + return nil + } + items.append(URLQueryItem(name: "context", value: json)) + } else { + items.append(URLQueryItem(name: "error", value: error)) + items.append(URLQueryItem(name: "error_description", value: description)) + } + components.queryItems = items + return components.url + } + private func buildRedirectURL(base: URL, code: String) -> URL? { guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false) else { return nil diff --git a/docs/external-api/pairing.mdx b/docs/external-api/pairing.mdx index de1181bb0..e2f710a81 100644 --- a/docs/external-api/pairing.mdx +++ b/docs/external-api/pairing.mdx @@ -154,6 +154,17 @@ For preferences-backed storage, use `updateCommandMetadata` or write to the pass A failed exchange is recorded in the activity log under the `auth` category with outcome `denied`. +### Denied approvals + +If the user clicks **Deny** on the approval sheet, TablePro opens the `redirect` URL with two extra parameters so the extension can show a clear error and stop spinning: + +- `error=denied` +- `error_description=user_denied` + +For `raycast://...` redirects these are wrapped inside the standard `context` JSON payload (`{"error":"denied","error_description":"user_denied"}`); for any other scheme they are appended as flat query parameters. + +Extensions should treat the presence of an `error` parameter on the callback as terminal and surface the description to the user. + ## Implementing pairing in another extension The flow is not Raycast-specific. Cursor, Claude Desktop, or any custom client can use it. Requirements: From a614dcbbad2d1d4ebffe13dcdbc355eeb0f75b51 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:17:50 +0700 Subject: [PATCH 43/54] feat(mcp): revoke prior tokens with the same name when re-pairing --- TablePro/Core/MCP/MCPPairingService.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/TablePro/Core/MCP/MCPPairingService.swift b/TablePro/Core/MCP/MCPPairingService.swift index 7f2fd010c..a5f9600da 100644 --- a/TablePro/Core/MCP/MCPPairingService.swift +++ b/TablePro/Core/MCP/MCPPairingService.swift @@ -141,6 +141,8 @@ final class MCPPairingService { throw error } + await Self.revokeExistingTokens(named: request.clientName, in: tokenStore) + let connectionAccess: ConnectionAccess = approval.allowedConnectionIds.map { .limited($0) } ?? .all let result = await tokenStore.generate( name: request.clientName, @@ -178,6 +180,14 @@ final class MCPPairingService { try store.consume(code: exchange.code, verifier: exchange.verifier) } + private static func revokeExistingTokens(named name: String, in store: MCPTokenStore) async { + let active = await store.activeTokens() + for token in active where token.name == name { + await store.revoke(tokenId: token.id) + Self.logger.info("Revoked previous token '\(name, privacy: .public)' before re-pairing") + } + } + private func startPruneLoop() { pruneTask = Task { [store] in while !Task.isCancelled { From 7c3f92f812e076ac3ccaf440da326e5ac1ed747c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:18:47 +0700 Subject: [PATCH 44/54] docs: note B1-M4 MCP fixes in CHANGELOG --- CHANGELOG.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4824ed5ba..73a7380bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - MCP: streaming progress notifications. Long-running tool calls (e.g. `execute_query`) now emit `notifications/progress` events to clients that pass a `_meta.progressToken` in their request. - -### Fixed +- MCP: pairing redirect carries an explicit `error=denied` parameter when the user clicks Deny so extensions can show a clear error instead of hanging. +- MCP: re-pairing the same client name automatically revokes the previous token instead of leaving it active. + +### Fixed +- MCP: GET `/mcp` now opens a real SSE notification stream. Previously the GET path was routed through the request dispatcher, which had no handler for it, so the connection was closed immediately and `notifications/progress` events were dropped. +- MCP: concurrent tool calls no longer serialize at the dispatcher loop. Each exchange is dispatched in its own child task while session-state guards still serialize per-session work. +- MCP: server validates the `protocolVersion` requested in `initialize` against a supported set and rejects unknown versions with `-32600 invalid_request` instead of silently echoing back whatever the client sent. +- MCP: server validates `MCP-Protocol-Version` on follow-up requests against the negotiated version on the session. +- MCP: 429 responses now include a real `Retry-After` header derived from the rate-limiter lockout time. The audit log records the same value. +- MCP: token revocation cancels every in-flight request issued by that token and terminates its sessions. +- MCP: CORS reflects the request `Origin` against an allowlist (`localhost`, `127.0.0.1`, `claude.ai`, `app.cursor.com`) instead of unconditionally returning `Access-Control-Allow-Origin: http://localhost`. Requests without an `Origin` header (native clients) get no CORS headers. +- MCP: duplicate `initialize` on the same session now returns `invalid_request` instead of silently overwriting `clientInfo`. +- MCP: `xcodebuild test` no longer leaves an orphan `TablePro.app` running. The app delegate skips its normal startup when launched under XCTest. +- MCP: server start removes a stale handshake file written by a crashed previous PID before writing a fresh one. +- MCP: settings activity log refreshes automatically when new audit entries are written. - MCP: stale `Mcp-Session-Id` after idle timeout now produces a JSON-RPC `-32001 "Session not found"` envelope with HTTP 404, matching the spec and letting clients re-initialize cleanly. Previously the bridge forwarded a plain `{"error":"Session not found"}` body that Claude Desktop's parser rejected, hanging the request until a 4-minute client-side timeout fired. - MCP: stdio bridge no longer exits silently when stdin is briefly empty (was reading via `availableData`, which can't tell EOF from "no bytes right now"). Now uses `FileHandle.bytes` AsyncBytes. - MCP: SSE responses now stream incrementally instead of buffering the entire body before delivering events. From 1c4a3abf035ed1adfa6591baf476f1fa79ad0f69 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:29:02 +0700 Subject: [PATCH 45/54] chore: untrack docs/refactor scratchpad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hig-audit notes were committed by accident during the doc audit pass. Untrack from index — keep on disk locally — so they don't ride along on future clones. --- docs/refactor/hig-audit/00-overview.md | 148 ---- docs/refactor/hig-audit/01-menus-shortcuts.md | 648 ------------------ .../hig-audit/02-windows-interactions.md | 394 ----------- docs/refactor/hig-audit/03-chrome-visual.md | 388 ----------- docs/refactor/hig-audit/04-system-document.md | 238 ------- 5 files changed, 1816 deletions(-) delete mode 100644 docs/refactor/hig-audit/00-overview.md delete mode 100644 docs/refactor/hig-audit/01-menus-shortcuts.md delete mode 100644 docs/refactor/hig-audit/02-windows-interactions.md delete mode 100644 docs/refactor/hig-audit/03-chrome-visual.md delete mode 100644 docs/refactor/hig-audit/04-system-document.md diff --git a/docs/refactor/hig-audit/00-overview.md b/docs/refactor/hig-audit/00-overview.md deleted file mode 100644 index 4b70fcdbf..000000000 --- a/docs/refactor/hig-audit/00-overview.md +++ /dev/null @@ -1,148 +0,0 @@ -# TablePro × macOS HIG Audit - -**Status**: Audit complete (2026-05-01). Refactor pending. -**Scope**: Full audit of TablePro against Apple macOS Human Interface Guidelines. -**Goal**: Identify every incorrect/non-native pattern. Refactor in dev stage before public release. - -## Why this audit - -TablePro is a native macOS database client. CLAUDE.md commits to "native only — no cross-platform abstractions". A `Cmd+N` review on 2026-05-01 found: - -- `Cmd+N` labeled "Manage Connections" — violates HIG "Cmd+N = New X" -- `CommandGroup(replacing: .newItem)` removes the default "New Window" entirely -- Behavior is `openOrFront()` (show window), not "create new" - -If one shortcut is wrong, others likely are too. We're in dev stage. Fix it all now, not after release. - -## Headline numbers - -**174 findings** across 4 domains: - -| Report | P0 | P1 | P2 | Total | -|--------|----|----|----|-------| -| [01 — Menus & Shortcuts](01-menus-shortcuts.md) | 13 | 30 | 27 | 70 | -| [02 — Windows & Interactions](02-windows-interactions.md) | 8 | 19 | 11 | 38 | -| [03 — Chrome & Visual](03-chrome-visual.md) | 18 | 17 | 7 | 42 | -| [04 — System & Document](04-system-document.md) | 7 | 11 | 6 | 24 | -| **Total** | **46** | **77** | **51** | **174** | - -P0 = broken native contract (will trip native users immediately). -P1 = non-idiomatic (works, but not how Apple/competitors do it). -P2 = polish (label wording, ellipsis, separator placement). - -## Cross-cutting themes - -Findings cluster around 5 root issues. Fixing each one collapses many leaf items. - -### T1 — No Apple document model - -TablePro hand-rolls SQL-file editing on top of `QueryTab` + manual `NSWindow.representedURL`/`isDocumentEdited` wiring. No `NSDocument`, `FileDocument`, or `DocumentGroup`. This single gap causes: - -- No Open Recent (01-File-menu, 04-system) -- No auto-save / Versions / Time Machine (04-system) -- No Revert / Duplicate / Rename / Move To… in File menu (01-File, 04-system) -- Custom quit-review alert reimplements `NSDocument` (04-system, `AppDelegate.swift:99`) -- "Save Changes" instead of "Save" (01-File) -- Cmd+W close logic that can produce empty windows (02-windows) - -**Fix**: Adopt `FileDocument` for `.sql` files, route through `DocumentGroup`. Apple gives the rest for free. - -### T2 — Keyboard shortcut sweep - -Five system-shortcut conflicts and several semantic mismatches in `KeyboardShortcutModels.swift`: - -- `Cmd+N` → "Manage Connections" (HIG: "New X") -- `Cmd+D` → "Save as Favorite" (HIG: "Duplicate") -- `Cmd+Y` → app action (system: Quick Look) -- `Cmd+Option+Delete` → app action (system: Empty Trash) -- `Cmd+Ctrl+C` → app action (system: Color Picker) -- `Cmd+L` → app action (system: URL bar focus; also collides with `Cmd+Shift+L = Format Query`) - -Plus missing Find submenu (Cmd+G / Cmd+Shift+G / Use Selection / Jump), no Cmd+1…9 tab quick-jump, label inconsistencies ("Toggle X" vs "Show/Hide X"). - -**Fix**: One sweep PR: rebind conflicts, add Find submenu, normalize labels. - -### T3 — Custom chrome where standard exists - -Visual chrome reimplements native components 80+ times: - -- 80 sites of `.font(.system(size:))` — none scale with Dynamic Type -- `WelcomeButtonStyle`, `KeyboardHint` badges, `TagBadgeView` capsules, custom inspector pills, custom popover selection backgrounds -- Welcome window hides traffic lights and standard title bar -- Hard-coded `.yellow` / `.pink` literals instead of `Color(nsColor: .systemYellow)` - -**Fix**: Mechanical removal in favor of `.bordered` / `.borderless` / semantic colors / `ContentUnavailableView` / system List selection. - -### T4 — Modal patterns are wrong - -Sheets stack on sheets in Export, Import, DatabaseSwitcher, ConnectionForm. License activation is called as a sheet from 6 different places. Every sheet containing a `TextEditor` or long form has no `minWidth`/`minHeight`. Destructive alerts use `.defaultAction` Return shortcut. - -**Fix**: License activation → standalone panel (one window, six triggers). Replace nested sheets with inline state or `NavigationStack` push. Add resize bounds to every sheet. Default-Cancel on destructive prompts. - -### T5 — Settings architecture is dated - -`SettingsView.swift` uses pre-Sonoma `TabView` toolbar with 9 tabs. macOS 14 standard (System Settings, Xcode 15, Notes) is `NavigationSplitView` with sidebar + detail. - -**Fix**: Migrate `Settings { TabView { ... } }` → `Settings { NavigationSplitView { ... } }`. - -## Recommended PR sequence - -Order matters: T1 first because it kills 6+ P0s on its own. T2 is mechanical and unblocks a clean menu structure. T3-T5 can run in parallel after. - -| # | PR | Theme | Effort | Kills | Notes | -|---|----|-------|--------|-------|-------| -| 1 | Adopt `FileDocument` + `DocumentGroup` for SQL files | T1 | L (multi-day) | ~6 P0, ~5 P1 | Foundation. Unlocks Open Recent, auto-save, Revert, quit-review for free. | -| 2 | Keyboard shortcut sweep (system conflicts + labels) | T2 | M | 6 P0, 8 P1 | Mostly mechanical. Touches `KeyboardShortcutModels.swift` + menu bindings. | -| 3 | Find submenu + Window menu completeness | T2 | S | 2 P0, 3 P1 | Add Cmd+G / Cmd+Shift+G / Cmd+E. Add "Show All Tabs", "Move Tab to New Window", "Merge All Windows". | -| 4 | Cmd+W close logic fix + tab close edge cases | T1/T2 | M | 1 P0 | Fix inverted single-tab close that produces empty windows. | -| 5 | Drop nested sheets across Export/Import/DB-switcher/ConnectionForm | T4 | L | 3 P0, 4 P1 | Cross-cutting. Each module needs its own refactor. | -| 6 | License activation → standalone `NSPanel` | T4 | M | 1 P0, 1 P1 | Six call sites today. One panel, six triggers. | -| 7 | Welcome window native chrome | T3 | M | 4 P0, 3 P1 | Restore title bar + traffic lights, drop `WelcomeButtonStyle`, drop `KeyboardHint`. | -| 8 | Typography sweep: 80 hard-coded sizes → semantic styles | T3 | M | 1 P0, 4 P1 | Mechanical, do per-folder. | -| 9 | Settings migration: `TabView` → `NavigationSplitView` | T5 | M | 1 P0, 2 P1 | Match macOS 14 System Settings. | -| 10 | Drop custom chrome: capsules, pills, KeyboardHint, TagBadgeView | T3 | M | 4 P0, 3 P1 | After PR 7 lands. | -| 11 | RightSidebar → Inspector (rename + restructure) | T3 | M | 2 P0, 2 P1 | Move mode picker, drop ALL CAPS section titles. | -| 12 | Sidebar filter search field + reduce min-width | T3 | S | 1 P0, 1 P1 | Add search above table list. | -| 13 | Empty states → `ContentUnavailableView` | T3 | M | 1 P0 | One pass across Welcome, Results, History, Editor placeholders. | -| 14 | Accessibility pass (icon-only labels, hints, reduce motion) | T3 | M | 3 P0, 1 P1 | Icon-only buttons need `.accessibilityLabel`. Welcome transition needs `accessibilityReduceMotion`. | -| 15 | Sheet sizing + destructive alert defaults | T4 | S | 2 P1 | Add `min/idealWidth` and `min/maxHeight` to sheets with editors. Set `hasDestructiveAction = true` everywhere. | -| 16 | Backspace vs forward Delete audit | T2 | S | 1 P1 | Replace `.onKeyPress(.delete)` with the `\u{7F}\u{08}` charset. | -| 17 | About panel: ship `Credits.rtf`, drop hand-built links | — | S | 1 P1 | Stop overriding `applicationDidFinishLaunching` for about. | -| 18 | Drag-out from data grid (TSV/HTML to clipboard/drag) | T4 | M | 1 P0 | Drop same-table-only check. | -| 19 | QuickSwitcher → floating panel; add Cmd+1…9 tab quick-jump | T2 | M | 1 P1 | Standalone panel anchored above key window. | -| 20 | Polish/P2 sweep | — | S | 51 P2 | Labels, ellipses, ALL CAPS, separator placement. Last. | - -**Out of scope for this audit (separate decisions)**: -- Mac App Store / sandboxing (`ENABLE_APP_SANDBOX = NO`) — flagged in 04-system but is a parallel-project decision, not a refactor. -- Mac Catalyst / iOS — covered in iOS roadmap. -- `UNUserNotificationCenter` for query/sync events — flagged in 04-system, not blocking. -- Quick Look extension for `.sql` — nice-to-have, defer. - -## Top 10 P0s by user-visible impact - -If we shipped today, these are what an Apple-fluent user would notice in the first 30 minutes: - -1. **Cmd+N opens "Manage Connections"** instead of creating something — `KeyboardShortcutModels.swift:460`, `TableProApp.swift:198` -2. **No Open Recent menu** — completely missing — `TableProApp.swift:204` -3. **No auto-save / no Revert / no Duplicate / no Rename / no Move To** — File menu is incomplete — `TableProApp.swift:204-256` -4. **Cmd+W can produce an empty window** — close logic inverted — `TabWindowController.swift` -5. **Cmd+D bound to "Save as Favorite", not "Duplicate"** — `KeyboardShortcutModels.swift` -6. **Cmd+Y / Cmd+Option+Delete / Cmd+Ctrl+C / Cmd+L collide with system shortcuts** — `KeyboardShortcutModels.swift` -7. **Welcome window has no traffic lights or standard title bar** — `WelcomeWindowFactory` -8. **80 hard-coded font sizes** — nothing scales with Dynamic Type — across `Views/` -9. **Sheets stack on sheets** — Export, Import, DatabaseSwitcher, ConnectionForm — multiple files in `Views/` -10. **Icon-only buttons missing `.accessibilityLabel`** — VoiceOver reads them as "Button" — across `Views/` - -## How to use this document - -- **Each PR**: pick one row from the sequence table. The detail file (`01`-`04`) has the exact `file:line` and fix. -- **Mark progress**: tick off rows in the sequence table as PRs land. -- **Don't bulk-merge**: each PR is bounded so review stays human-sized. -- **Don't drop P2s**: they're polish, but they accumulate. Schedule PR 20 for end-of-stream. - -## Audit metadata - -- Run on 2026-05-01 with 4 specialist agents in parallel: `menu-shortcut-auditor`, `window-interaction-auditor`, `chrome-visual-auditor`, `system-document-auditor`. -- Audit team: `~/.claude/teams/hig-audit/`. Task list: `~/.claude/tasks/hig-audit/`. -- Source pinned at branch `feat/raycast-integration`, commit `2f5b4f8e`. -- Auditors were read-only; no source files were modified during the audit. diff --git a/docs/refactor/hig-audit/01-menus-shortcuts.md b/docs/refactor/hig-audit/01-menus-shortcuts.md deleted file mode 100644 index a52cddd21..000000000 --- a/docs/refactor/hig-audit/01-menus-shortcuts.md +++ /dev/null @@ -1,648 +0,0 @@ -# HIG Audit: Menus & Keyboard Shortcuts - -Audit of `TablePro/` against Apple's macOS Human Interface Guidelines for menu -structure, menu wording, command placement, and keyboard shortcut semantics. - -Source of truth audited: - -- `TablePro/Models/UI/KeyboardShortcutModels.swift` (`KeyboardSettings.defaultShortcuts`) -- `TablePro/TableProApp.swift` (`AppMenuCommands`, `PasteboardCommands`) -- Context menus in `TablePro/Views/**` -- AppKit menus in `TablePro/Views/Results/TableRowViewWithMenu.swift`, - `TablePro/Views/Editor/AIEditorContextMenu.swift`, - `TablePro/Views/Terminal/TerminalTabContentView.swift` - -The pre-existing finding for `Cmd+N` -> "Manage Connections" is intentionally -excluded; everything else below is new. - ---- - -## P0 — Broken native contracts - -### [P0] `Cmd+Option+Delete` is the system shortcut for "Empty Trash" - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:493` -- **Current**: `.truncateTable: KeyCombo(key: "delete", option: true, isSpecialKey: true)` (no Cmd modifier; raw `Option+Delete`). -- **HIG says**: `Option+Delete` (without Command) deletes the previous word in any text field. `Shift+Cmd+Delete` is "Empty Trash" in Finder. Bare modified-Delete combinations on table data are dangerous because the same chord may be interpreted as a destructive Finder action when focus is ambiguous, and `Option+Delete` already has a meaning in any text field that takes focus inside the data grid. -- **Native examples**: Finder "Empty Trash" `Shift+Cmd+Delete`; `Option+Delete` deletes-word in TextEdit, Mail, Notes, Safari URL bar. -- **Fix**: Drop a default shortcut entirely for `truncateTable`. Truncating an entire table is a rare, destructive, multi-step operation; it should require an explicit menu pick or context menu and a confirmation sheet. If a default is desired, scope it to data-grid focus only and pick a non-text-mutating chord (e.g. `Cmd+Backspace` only when the sidebar/data grid is first responder). -- **Effort**: S - -### [P0] `Cmd+L` collides with the system "address bar" semantic and Apple's text-list shortcut - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:512` -- **Current**: `.aiExplainQuery: KeyCombo(key: "l", command: true)`. Bare `Cmd+L` triggers an AI feature that isn't even visible in the toolbar by default. -- **HIG says**: `Cmd+L` is the macOS "Open Location" / address-bar / link-to convention (Safari, Chrome, Mail "Add Link", Messages "Add Link", any Finder window with "Go to Folder" via `Cmd+Shift+G`). Allocating `Cmd+L` to an AI explanation is surprising and steals a system-typical chord. -- **Native examples**: Safari, Chrome, Firefox -> focus URL bar. Notes / Mail -> add link. Pages -> "Show/Hide List Format Inspector". -- **Fix**: Move AI to the standard "Writing Tools" / "Smart" cluster: `Cmd+Ctrl+E` or `Cmd+Shift+A` (DataGrip uses `Cmd+Shift+A` for "Find Action"; Xcode reserves `Cmd+Shift+A` for "Action") - or, since AI features already live behind a settings toggle, ship without a default shortcut and let users assign one in Settings -> Keyboard. -- **Effort**: S - -### [P0] `Cmd+D` is mapped to "Save as Favorite", inverting the macOS "Duplicate" convention - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:495` (`.saveAsFavorite`), `TablePro/Models/UI/KeyboardShortcutModels.swift:492` (`.duplicateRow`). -- **Current**: `Cmd+D` -> Save as Favorite. `Cmd+Shift+D` -> Duplicate Row. -- **HIG says**: `Cmd+D` is the standard "Duplicate" chord across Finder, Pages, Numbers, Keynote, Photos, and the AppKit responder chain (`duplicate:`). `Cmd+Shift+D` has no fixed Apple meaning, but Mail uses it for "Send Again" and Safari for "Add Bookmark to Favorites". Putting Duplicate behind Shift inverts user muscle memory and competes with macOS's own bookmarking chord on `Cmd+D`. -- **Native examples**: Finder "Duplicate" `Cmd+D`; Pages/Keynote/Numbers "Duplicate Selection" `Cmd+D`; Photos "Duplicate" `Cmd+D`. -- **Fix**: Bind `Cmd+D` to `.duplicateRow`. Move `.saveAsFavorite` to `Cmd+Shift+D` (matches Safari "Add to Favorites") or to a non-conflicting modifier such as `Cmd+Option+S`. -- **Effort**: S - -### [P0] `Cmd+Y` is reserved by macOS for Quick Look - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:501` -- **Current**: `.toggleHistory: KeyCombo(key: "y", command: true)` (bare `Cmd+Y`). -- **HIG says**: `Cmd+Y` is the system Quick Look shortcut (Finder, Mail, Messages). Apple also uses `Cmd+Y` for "Show History" in Safari, but that is a window-opening action, not a sidebar toggle. Either way, the bare `Cmd+Y` chord is owned by Finder Quick Look, and shadowing it with a panel toggle is non-idiomatic. -- **Native examples**: Finder/Mail/Messages -> Quick Look. Safari -> Show History (full window, not a side panel). -- **Fix**: Move history-panel toggle to a chord consistent with the other side-panel toggles in the app: e.g. `Cmd+Option+H` (mirrors `Cmd+Option+I` for Inspector) or `Cmd+Shift+H` (note: macOS reserves `Cmd+Shift+H` for "Go Home" in Finder, so prefer `Cmd+Option+H`). -- **Effort**: S - -### [P0] `Cmd+Shift+E` overlaps with Mail's "Send Later" / Notes' "Show All Tags" but more importantly inverts the menu-vs-shortcut hierarchy - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:473` (`.export`), `TablePro/Models/UI/KeyboardShortcutModels.swift:471` (`.explainQuery`). -- **Current**: `.export` = `Cmd+Shift+E`, `.explainQuery` = `Cmd+Option+E`. Both `Cmd+Shift+E` and `Cmd+Option+E` are non-standard chords and both share the `e` key, leading to conflicts when users are scanning a Query menu. -- **HIG says**: Apple does not reserve `Cmd+E` for Export; its meaning across apps is "Use Selection for Find" (`findFromSelection:`). Shift/Option variants of `Cmd+E` do not have Apple-mandated meanings, but stacking two near-identical `e` chords on different actions in adjacent menus violates "Avoid Modifier Combinations That Are Hard to Remember" (HIG: Keyboard). -- **Native examples**: Numbers "Export to..." has no default keyboard shortcut. Pages "Export to..." has no default keyboard shortcut. Xcode "Export..." has no default keyboard shortcut. Apps that bind Export typically use `Cmd+Shift+E` (VS Code) but never alongside another `e` chord. -- **Fix**: Drop the default shortcut for `.explainQuery` (it lives behind a menu item and is rarely used by keyboard). Keep `Cmd+Shift+E` for Export only. -- **Effort**: S - -### [P0] `Cmd+R` for "Refresh" is in the Query menu, not the View menu - -- **File**: `TablePro/TableProApp.swift:344-348` (Refresh button is inside `CommandMenu("Query")`). -- **Current**: Refresh sits in the Query menu next to Execute / Format / Cancel. -- **HIG says**: `Cmd+R` is universally a refresh/reload action and the menu placement convention is the View menu (Safari "Reload Page", Mail "Get New Mail", Finder "Refresh") or the Window menu (older apps). Putting it in Query implies the action only refreshes the query, when it actually fires the global `.refreshData` notification (sidebar + coordinator + structure view) and reloads the selected table. -- **Native examples**: Safari View > Reload Page `Cmd+R`. Mail Mailbox > Get New Mail `Cmd+Shift+N` (but `Cmd+R` reloads). Xcode View > Reload `Cmd+Shift+H`. -- **Fix**: Move "Refresh" into the View menu. Optionally add a separate "Re-run Query" item in the Query menu if desired (but `.executeQuery` already covers that). -- **Effort**: S - -### [P0] "Switch Connection..." and "Quick Switcher..." are in the Query menu - -- **File**: `TablePro/TableProApp.swift:386-390` (Switch Connection in Query), `TablePro/TableProApp.swift:350-354` (Quick Switcher in Query). -- **Current**: Both connection-level navigation actions are in the Query command menu. -- **HIG says**: The Query menu's purpose is operations on the current query/result. Switching the active connection or jumping to another table is a File-menu or Window-menu concern. Apple HIG: "Group menu items by the kind of action they perform." -- **Native examples**: TablePlus "Switch Connection..." in File menu. Sequel Ace "Choose Connection..." in File menu. DataGrip "Open Recent" in File menu. -- **Fix**: Move "Switch Connection..." to File. Move "Quick Switcher..." to File (or keep as Window-menu "Show Tab Bar" style action). Cmd+K and Cmd+Shift+O semantics also drift toward "open something" rather than "do something with this query". -- **Effort**: S - -### [P0] "Open Database..." (`Cmd+K`) collides with Finder's "Connect to Server..." - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:463` (`.openDatabase`). -- **Current**: `Cmd+K` opens the database switcher (the panel that lets you pick which database/schema this connection should target). -- **HIG says**: `Cmd+K` in macOS is "Connect to Server" in Finder, "Add Link" in mail/notes, "Clear Screen" in Terminal. It is not a "switch context within the current connection" chord. Furthermore, "Open Database" reads like an action that opens a `.sqlite` file - which would conflict with `.openFile` (`Cmd+O`). -- **Native examples**: Xcode `Cmd+Shift+O` "Open Quickly". TablePlus "Switch Database" `Cmd+K` (TablePlus has the same problem). DataGrip "Switch Database" no default chord. -- **Fix**: Rename the menu item to "Switch Database..." (it is not opening anything). Move the chord to `Cmd+Shift+K` or unbind by default. `Cmd+K` is also the de-facto chord for the AI "Command Palette" pattern in modern editors (Cursor, Continue) - consider that too. -- **Effort**: S - -### [P0] "Open File..." labeled and shortcut-mapped as a generic file open, but it is SQL-only - -- **File**: `TablePro/TableProApp.swift:222-226`, `TablePro/Models/UI/KeyboardShortcutModels.swift:464`. -- **Current**: Menu item "Open File..." with `Cmd+O` calls `actions?.openSQLFile()`. -- **HIG says**: `Cmd+O` is the standard Open File chord and users expect a generic `NSOpenPanel` that accepts "everything this app can read". TablePro can also open `.sqlite`/`.duckdb` database files (per the registered UTIs), and connection import files (`.tablepro`). The single "Open File..." entry only handles SQL. This violates HIG "Make a menu item's behavior match its name". -- **Native examples**: TextEdit, Pages, Xcode -> Open File dialog supports all known doc types. Finder Open With -> uses UTIs. -- **Fix**: Either rename to "Open SQL File..." (clearer scope) or expand the Open dialog to accept SQL + import + standalone database files and route appropriately in the openHandler. -- **Effort**: M - -### [P0] "Toggle Sidebar" / "Toggle Inspector" / "Toggle Filters" / "Toggle History" / "Toggle Results" use static "Toggle" labels - -- **File**: `TablePro/TableProApp.swift:461,466,474,480,488` (View menu). -- **Current**: All five menu items are statically labeled "Toggle X". The label never changes when the panel is open vs closed. -- **HIG says**: "Use accurate, descriptive titles for menu items. ... When a menu item toggles between two states, change the title to reflect the action it will perform." (Apple HIG: Menus -> Use Toggle Items Sparingly.) Apple's own apps use "Show Sidebar" / "Hide Sidebar". -- **Native examples**: Finder "Show Sidebar" / "Hide Sidebar" (`Cmd+Ctrl+S`). Mail "Show Mailbox List" / "Hide Mailbox List". Xcode "Show Navigator" / "Hide Navigator". Notes "Show Folders" / "Hide Folders". -- **Fix**: Read the panel state at menu build time and switch labels: `splitViewController.isSidebarCollapsed ? "Show Sidebar" : "Hide Sidebar"`, etc. Same for inspector/filters/history/results. -- **Effort**: M (state has to be observable from the menu builder; `@FocusedValue` already provides the actions object - extend it with `isFilterPanelVisible`, `isInspectorVisible`, ...). - -### [P0] `Cmd+Ctrl+C` for "Switch Connection" is reserved by macOS for the Color Picker - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:465` -- **Current**: `.switchConnection: KeyCombo(key: "c", command: true, control: true)`. -- **HIG says**: `Cmd+Ctrl+C` is the system-wide Color Picker shortcut on macOS (NSColorPanel's pickup tool); it also clashes with VoiceOver `Cmd+Ctrl+C` "Read All". Reusing it is an accessibility regression. -- **Native examples**: Apple Color Picker, VoiceOver. -- **Fix**: Drop the default. Leave switching to the menu item (the user can rebind in Settings -> Keyboard if they want a chord). If a default is needed, prefer `Cmd+Shift+C` (note Mail uses `Cmd+Shift+C` for "Reply with iMessage" but TablePro is not in Mail's contention space). -- **Effort**: S - -### [P0] `Cmd+Ctrl+`` for "Open Terminal" is non-standard and ambiguous with Cmd+`` (window cycling) - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:476`. -- **Current**: `.openTerminal: KeyCombo(key: "`", command: true, control: true)`. -- **HIG says**: `Cmd+`` is the macOS window-cycling chord for the same app (already in `KeyCombo.systemReserved`). Adding `Ctrl` produces a chord that is genuinely free, but the convention for "Show Terminal" in IDEs is `Cmd+Option+T` (DataGrip), `Cmd+`` (VS Code, conflicts with system), or `Ctrl+`` (Xcode does not have a built-in terminal). -- **Native examples**: VS Code `Ctrl+`` (without Cmd) toggles terminal. JetBrains `Cmd+Option+0` shows the Terminal tool window. Xcode opens external Terminal. -- **Fix**: Either drop the default chord, or move to `Cmd+Option+T` (matches DataGrip) which has no built-in macOS conflict. Keep the menu item under "View" only if the terminal is a panel; if it opens a separate window, move it to File ("Open Terminal Window"). -- **Effort**: S - -### [P0] No "New Window" (`Cmd+N`) anywhere in the menu - -- **File**: `TablePro/TableProApp.swift:197-202` (already-known finding for the wrong `Cmd+N` mapping). -- **Current**: `CommandGroup(replacing: .newItem)` removes SwiftUI's default New Window entry entirely. There is no replacement; users have no way to spawn a fresh main window. -- **HIG says**: HIG Window Menu / File Menu both call out "New Window" as a standard, app-level action. Document-based apps must support `Cmd+N`. TablePro is not formally document-based, but the connection-tabbed window is its document analogue. -- **Native examples**: Safari File > New Window `Cmd+N`. Mail File > New Viewer Window `Cmd+Option+N`. Xcode File > New > Window `Cmd+Ctrl+N`. -- **Fix**: Add an explicit "New Main Window" entry (e.g. `Cmd+Ctrl+N`) that opens a new `TabWindowController` for the most-recently-active connection. Or repurpose the to-be-renamed `Cmd+N` ("New Connection") and add `Cmd+Shift+N` for "New Window". -- **Effort**: M - ---- - -## P1 — Non-idiomatic placement, modifier conventions, label problems - -### [P1] "GitHub Repository" wording is inconsistent with the rest of the Help menu - -- **File**: `TablePro/TableProApp.swift:594`. -- **Current**: Help menu has "TablePro Website", "Documentation", and then "GitHub Repository". -- **HIG says**: Help-menu entries should describe the user-visible result, not the technical artifact. "Repository" is a Git concept; users expect a verb-noun or noun phrase like "TablePro on GitHub" / "Source Code on GitHub". -- **Native examples**: Notion Help menu "Visit Notion", Linear "Linear on GitHub". Native apps rarely link to GitHub but follow noun-phrase patterns (Mail "About Mail Filtering"). -- **Fix**: Rename to "TablePro on GitHub" or "Source Code on GitHub". -- **Effort**: S - -### [P1] Help menu omits the standard "TablePro Help" item - -- **File**: `TablePro/TableProApp.swift:583-603` (`CommandGroup(replacing: .help)`). -- **Current**: Replaces the entire Help menu with website / documentation / GitHub / report-issue. -- **HIG says**: Apple HIG (Help menu): "Provide a Help menu and a Help command, and use the standard Help title (App Name Help)." The standard menu item should be "TablePro Help" pointing at help content (in this case: docs.tablepro.app). The Help search field is preserved automatically. -- **Native examples**: Mail "Mail Help", Safari "Safari Help", Notes "Notes Help". All point to Apple-hosted help books or web docs. -- **Fix**: Keep the existing items but rename "Documentation" to "TablePro Help" and place it first, as the canonical Help entry. The search field will continue to work. -- **Effort**: S - -### [P1] "Save As..." chord (`Cmd+Shift+S`) without the Apple-recommended hidden default of "Duplicate" - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:467`, `TablePro/TableProApp.swift:242-246`. -- **Current**: `Cmd+Shift+S` invokes "Save As...". -- **HIG says**: Since macOS 10.7 Apple recommends `Cmd+Shift+S` = "Duplicate" by default and reveals "Save As..." only when the user holds `Option` (Pages, Numbers, Keynote, TextEdit). Document-based apps follow this. TablePro's `.saveFileAs()` is closer to a Pages "Save As" so this is borderline-acceptable, but worth noting. -- **Native examples**: Pages, Numbers, Keynote, TextEdit, Preview - all show "Duplicate" by default and toggle to "Save As..." on Option. -- **Fix**: Hold for now (TablePro is not document-based and "Duplicate" doesn't map to anything sensible). If TablePro ever adds a true SQL-document mode, revisit. -- **Effort**: M (defer) - -### [P1] "Refresh" sits in the Query menu but uses the View menu chord - -- **File**: `TablePro/TableProApp.swift:344-348`. -- **Current**: Refresh in Query menu, `Cmd+R`. -- **HIG says**: See P0 above for placement; chord is correct. Same fix moves both. -- **Effort**: S (combined with the P0 above) - -### [P1] "Cancel Query" (Cmd+.) is correct but lacks ellipsis-or-not consistency - -- **File**: `TablePro/TableProApp.swift:338-342`. -- **Current**: "Cancel Query" - no ellipsis (correct, it acts immediately). -- **HIG says**: Cancel actions never take ellipsis. Already correct. -- **Note**: Just confirming. No change. - -### [P1] Top-level "Query" menu name overlaps with another database client convention - -- **File**: `TablePro/TableProApp.swift:296` (`CommandMenu("Query")`). -- **Current**: Top-level menu is named "Query", containing Execute / Explain / Format / Refresh / Quick Switcher / Switch Connection / Save as Favorite / AI / Preview FK. -- **HIG says**: Custom menus are allowed but should be tightly scoped. Today the Query menu is a grab-bag of unrelated actions (FK preview, AI, connection switching, refresh). HIG: "Limit the scope of each menu so that it contains related items only." -- **Native examples**: TablePlus uses two menus: "Connection" and "Query". DataGrip uses "Database" and "Code". -- **Fix**: Split into two menus: "Database" (Switch Connection, Switch Database, Refresh, Quick Switcher, Server Dashboard) and "Query" (Execute, Execute All, Explain, Format, Cancel, Preview SQL, AI, Preview FK). -- **Effort**: M - -### [P1] AI commands placed at the bottom of "Query" with no visual section header - -- **File**: `TablePro/TableProApp.swift:366-376`. -- **Current**: `Divider()` + two AI buttons inside Query. -- **HIG says**: When a feature cluster (AI) is conditionally available (settings flag), Apple typically places it under its own submenu or hides it entirely when off. Right now AI menu items remain enabled but call into a feature that can be off, leaking surface area. -- **Native examples**: Apple Intelligence "Writing Tools" submenu in Mail, Notes (single "Writing Tools..." entry). -- **Fix**: Group AI under a `Menu("AI")` submenu in Query. Also disable when `AppSettingsManager.shared.ai.enabled == false`. -- **Effort**: S - -### [P1] Edit menu lacks a Find submenu structure - -- **File**: `TablePro/TableProApp.swift:430-433`. -- **Current**: Single "Find..." item, `Cmd+F`. No "Find Next" / "Find Previous" / "Use Selection for Find" / "Replace...". -- **HIG says**: Apple's standard Find submenu in TextEdit / Mail / Pages contains: Find..., Find Next, Find Previous, Use Selection for Find, Jump to Selection. The Edit menu lays them out as a `Menu("Find")` submenu or in the dedicated Find category. -- **Native examples**: TextEdit Edit > Find submenu. Mail Edit > Find submenu. Xcode Find menu (separate top-level). -- **Fix**: Add Edit > Find submenu with Find / Find Next (`Cmd+G`) / Find Previous (`Cmd+Shift+G`) / Use Selection for Find (`Cmd+E`) / Jump to Selection (`Cmd+J`). Most are already supported by the underlying CodeEditTextView - only need menu items routing through `findFromSelection:` etc. -- **Effort**: M - -### [P1] Edit menu lacks Spelling / Substitutions submenus - -- **File**: `TablePro/TableProApp.swift` (Edit menu). -- **Current**: No Spelling and Grammar submenu. SwiftUI's `CommandGroup(replacing: .pasteboard)` removes the entire pasteboard cluster and rebuilds it; the Spelling submenu lives outside that group and would be auto-included, but TablePro overrides too aggressively (see next finding). Verify behavior. -- **HIG says**: The Edit menu standard order is: Undo/Redo, Cut/Copy/Paste/Delete/Select All, Find, Spelling and Grammar, Substitutions, Speech, AutoFill. Most are auto-injected by SwiftUI when you don't replace the relevant CommandGroups. -- **Native examples**: TextEdit, Mail, Notes - all show Spelling, Substitutions, Speech. -- **Fix**: Verify which default CommandGroups are still applied. If Spelling is missing in the SQL editor (where it makes sense for comments), add it. -- **Effort**: S - -### [P1] "Increase Text Size" / "Decrease Text Size" wording - -- **File**: `TablePro/TableProApp.swift:532-540`. -- **Current**: "Increase Text Size" `Cmd+=` / "Decrease Text Size" `Cmd+-`. -- **HIG says**: Apple's standard wording is "Make Text Bigger" / "Make Text Smaller" (Mail, Safari, Messages, Notes). "Increase/Decrease" is engineering-speak. -- **Native examples**: Safari View > Zoom In `Cmd++` / Zoom Out `Cmd+-`. Mail Format > Style > Bigger `Cmd++`. Notes "Make Text Bigger" `Cmd++`. -- **Fix**: Rename to "Bigger" / "Smaller" or "Zoom In" / "Zoom Out". Also add an "Actual Size" `Cmd+0` ... but `Cmd+0` is taken by `.toggleTableBrowser` (see P2). -- **Effort**: S - -### [P1] `Cmd+0` (`.toggleTableBrowser`) overlaps with the universal "Actual Size" convention - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:498`. -- **Current**: `Cmd+0` toggles the sidebar. -- **HIG says**: `Cmd+0` is "Actual Size" in Safari, Preview, Photos. Xcode does use `Cmd+0` for "Show Navigator", which gives TablePro precedent, but mixing the two conventions is confusing in an app that also has zoom shortcuts. -- **Native examples**: Xcode `Cmd+0` Show Navigator (matches TablePro). Safari/Preview/Photos `Cmd+0` Actual Size. -- **Fix**: Match Apple's HIG default for sidebars: `Cmd+Ctrl+S` (Finder, Mail). Free up `Cmd+0` for a future "Actual Size" / "Reset Zoom" if the editor zoom is added. -- **Effort**: S - -### [P1] `Cmd+Shift+F` (`.toggleFilters`) collides with IDE "Find in Project" - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:500`. -- **Current**: `Cmd+Shift+F` toggles the filter panel. -- **HIG says**: `Cmd+Shift+F` is "Find in Project / All Files" in every IDE (Xcode, VS Code, JetBrains, Sublime). TablePro does not have a global find, but using this chord for a panel toggle wastes the slot. -- **Native examples**: Xcode "Find in Project" `Cmd+Shift+F`. VS Code "Find in Files" `Cmd+Shift+F`. -- **Fix**: Move filter-panel toggle to `Cmd+Option+F` (matches the other Option-modified panel toggles like `Cmd+Option+I` for Inspector). -- **Effort**: S - -### [P1] "New Tab" (`Cmd+T`) opens a query tab but does not clarify that - -- **File**: `TablePro/TableProApp.swift:205-208`, `TablePro/Models/UI/KeyboardShortcutModels.swift:462`. -- **Current**: "New Tab" `Cmd+T` calls `actions?.newTab()`. -- **HIG says**: HIG: "Be clear about what tabs are." `Cmd+T` is universally "new tab in this window" (Safari, Terminal, Finder, Xcode). TablePro's tab is a query-editor tab. Label should be "New Query Tab" if a future "New Connection Tab" is conceivable. -- **Native examples**: Safari "New Tab" - one tab type. Xcode "New Tab" - one tab type. Terminal "New Tab" - one tab type. -- **Fix**: Hold. Acceptable as-is. - -### [P1] "Save Changes" wording - -- **File**: `TablePro/TableProApp.swift:230-233`. -- **Current**: Menu item "Save Changes" `Cmd+S`. -- **HIG says**: Apple's File menu standard label is "Save" - never "Save Changes". The "Changes" suffix is implied by the verb. HIG: "Use short, simple verbs." -- **Native examples**: TextEdit, Pages, Xcode - all say "Save". -- **Fix**: Rename menu item to "Save". (The action's `displayName` in `KeyboardShortcutModels.swift:128` can stay "Save Changes" if the settings UI explicitly differentiates from Save File - but the menu label should match Apple's.) -- **Effort**: S - -### [P1] "Manage Connections" should follow `New X` ellipsis convention - -- **File**: `TablePro/TableProApp.swift:198`. -- **Current**: "Manage Connections" with no ellipsis. Already known the chord is wrong; separately the wording lacks an ellipsis even though the action opens a separate window (the Welcome window). -- **HIG says**: HIG: "Append an ellipsis to the title of any menu item that requires further input from the person before the action takes place." Opening a separate window for management is a borderline case - some Apple apps use ellipsis, some don't. The rule of thumb: if the user has to do anything in the new window before something happens, add ellipsis. -- **Native examples**: System Settings > Network "Manage Locations..." (ellipsis). Mail "Manage Mailboxes..." (ellipsis). -- **Fix**: When this is renamed to "New Connection..." per the existing finding, the ellipsis is correct. -- **Effort**: S (folded into existing finding) - -### [P1] "Quick Switcher..." in `KeyboardShortcutModels` displayName is fine, but the menu label should specify what is being switched - -- **File**: `TablePro/TableProApp.swift:350`. -- **Current**: "Quick Switcher..." `Cmd+Shift+O`. -- **HIG says**: The label is generic. Users don't know if it switches connections, tables, queries, or all of the above. HIG: "Use accurate, descriptive titles." -- **Native examples**: Xcode "Open Quickly..." `Cmd+Shift+O`. VS Code "Go to File..." `Cmd+P`. -- **Fix**: Rename to "Open Quickly..." (matches Apple/Xcode) or "Go to..." with a specific scope. -- **Effort**: S - -### [P1] "Preview SQL" label is dynamic with `String(format:)` but uses placeholder for unconnected state - -- **File**: `TablePro/TableProApp.swift:321-329`. -- **Current**: Shows "Preview SQL" when no connection, otherwise "Preview \(language)" (e.g. "Preview MongoDB"). -- **HIG says**: Menu items should not change between connected and disconnected states except to enable/disable. The label flicker between "Preview SQL" and "Preview MongoDB" violates HIG: "Maintain stable menu item titles where possible". -- **Native examples**: Xcode menus do not change titles based on document type. -- **Fix**: Always show "Preview Statement..." or "Preview Pending Changes..." (the latter is more honest - this previews INSERT/UPDATE/DELETE for pending row edits). The current label even misleads users into thinking it previews the editor query. -- **Effort**: S - -### [P1] AI menu items in editor right-click only show when text is selected, hiding the feature - -- **File**: `TablePro/Views/Editor/AIEditorContextMenu.swift:75-96`. -- **Current**: `guard AppSettingsManager.shared.ai.enabled, hasSelection?() == true else { return }` - AI items disappear entirely when no selection. -- **HIG says**: HIG: "Prefer disabling a menu item to removing it." Menu items that vanish based on selection are jarring; users learn the menu's geometry by repetition. -- **Native examples**: Notes "Writing Tools" submenu always visible, individual items disable when nothing is selected. -- **Fix**: Always show the AI items, disable them when `hasSelection?() != true`. -- **Effort**: S - -### [P1] Right-click on data grid row does not show a "Show in Sidebar" / "Reveal" type entry, but does show "Open " inconsistently - -- **File**: `TablePro/Views/Results/TableRowViewWithMenu.swift:119-126` (FK navigation). -- **Current**: FK preview/navigation only appears when the column is a foreign key AND the cell has a value. Reasonable, but the sub-section appears mid-menu without a label. -- **HIG says**: Conditional sub-sections in context menus should be labeled (use a non-clickable header item or leading divider with a `Menu` submenu) when they appear/disappear based on context. HIG: "Group related items." -- **Fix**: Wrap FK actions in a `Menu("Foreign Key")` submenu, or move them under a labelled section. Otherwise the row context menu shifts visually each time. -- **Effort**: S - -### [P1] Result tab right-click menu uses non-standard wording for its "Pin/Unpin" toggle but proper Show/Hide-style toggling - -- **File**: `TablePro/Views/Results/ResultTabBar.swift:62-72`. -- **Current**: `Button(rs.isPinned ? String(localized: "Unpin") : String(localized: "Pin Result"))`. -- **HIG says**: Toggle wording should match: either "Pin Result" / "Unpin Result" (matched verb-noun pair) or "Pin" / "Unpin" (matched single verb). Current pair is mismatched. -- **Native examples**: Safari pinned tabs: "Pin Tab" / "Unpin Tab". -- **Fix**: "Pin Result" / "Unpin Result". -- **Effort**: S - -### [P1] Sidebar context menu "Create New Table..." and "Create New View..." but File menu uses "New View..." (no Create prefix) - -- **File**: `TablePro/Views/Sidebar/SidebarContextMenu.swift:59,64` vs `TablePro/TableProApp.swift:211`. -- **Current**: Sidebar context menu prefixes with "Create"; File menu omits it. -- **HIG says**: HIG: "Use consistent terminology." If the action is the same, the label should be the same. -- **Native examples**: Finder "New Folder" (no "Create"). Pages "New Document" (no "Create"). -- **Fix**: Standardize on "New Table..." / "New View..." (drop "Create"). -- **Effort**: S - -### [P1] Welcome window context menu "Edit" lacks the noun ("Edit Connection") - -- **File**: `TablePro/Views/Connection/WelcomeContextMenus.swift:97`. -- **Current**: `Label(String(localized: "Edit"), systemImage: "pencil")`. -- **HIG says**: Bare verbs in context menus are ambiguous when a row is selected. Should be "Edit Connection" so the action is unambiguous when read out by VoiceOver, or screenshotted, or skim-read. -- **Native examples**: Mail context menu "Edit Account..." not "Edit". Calendar "Edit Event" not "Edit". -- **Fix**: "Edit Connection" / "Duplicate Connection" / "Delete Connection". -- **Effort**: S - -### [P1] Welcome window deletion path uses "Delete" without ellipsis but opens a confirm dialog - -- **File**: `TablePro/Views/Connection/WelcomeContextMenus.swift:74-81,183-188`. -- **Current**: "Delete" / "Delete %d Connections" - no ellipsis - even though `vm.showDeleteConfirmation = true` opens a confirmation sheet. -- **HIG says**: Apple's HIG (Menus): the ellipsis indicates the user must take additional steps before the action completes. A destructive confirm dialog counts. -- **Native examples**: Finder "Move to Trash" (no ellipsis - the action is the one-step move). But "Delete Immediately..." has ellipsis because it requires confirmation. -- **Fix**: Either remove the confirmation dialog (menus already provide enough friction for simple deletes via the destructive role styling) or add ellipsis: "Delete...". -- **Effort**: S - -### [P1] "Bring All to Front" duplicated between SwiftUI default and custom Window menu group - -- **File**: `TablePro/TableProApp.swift:575-577`. -- **Current**: Adds a "Bring All to Front" button under `CommandGroup(after: .windowArrangement)`. SwiftUI's default Window menu already includes this. -- **HIG says**: Standard menu items must not appear twice. -- **Native examples**: Every Apple app has exactly one "Bring All to Front". -- **Fix**: Remove the manually-added "Bring All to Front" button. -- **Effort**: S - -### [P1] "Cancel Query" in Query menu uses `Cmd+.` but sits below higher-frequency items - -- **File**: `TablePro/TableProApp.swift:336-342`. -- **Current**: Cancel Query is below Format Query / Preview SQL. -- **HIG says**: HIG: "Order menu items by frequency or importance." Cancel is a critical, high-importance action - should sit just below Execute / Execute All. -- **Fix**: Reorder the Query menu so Execute / Execute All / Cancel cluster at the top. -- **Effort**: S - -### [P1] Hardcoded Find shortcut `Cmd+F` is not customizable through `KeyboardSettings` - -- **File**: `TablePro/TableProApp.swift:431-433`. -- **Current**: `.keyboardShortcut("f", modifiers: .command)` is hardcoded; `KeyboardSettings.defaultShortcuts` has no `.find` action. -- **HIG says**: Not strictly a HIG violation, but inconsistent with the rest of the menu (every other shortcut routes through `optionalKeyboardShortcut(shortcut(for:))` so users can rebind). -- **Fix**: Add `.find` to `ShortcutAction` and `defaultShortcuts`, route through the same path. -- **Effort**: S - -### [P1] Hardcoded Execute / Execute All Statements / Cancel / Bigger / Smaller shortcuts are not customizable - -- **File**: `TablePro/TableProApp.swift:300, 306, 341, 535, 540`. -- **Current**: `.keyboardShortcut(.return, modifiers: .command)`, `[.command, .shift]`, `Cmd+.`, `Cmd+=`, `Cmd+-` all hardcoded. -- **HIG says**: Same as above - inconsistent customization story. -- **Fix**: Add corresponding `ShortcutAction` cases (`.executeAllStatements`, `.cancelQuery`, `.makeTextBigger`, `.makeTextSmaller`) and route through `KeyboardSettings`. -- **Effort**: M - -### [P1] `KeyCombo.systemReserved` list is incomplete - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:360-376`. -- **Current**: 15 reserved chords listed. -- **HIG says**: macOS reserves many more system chords for accessibility (VoiceOver `Cmd+F5`, Zoom `Cmd+Option+8`/`Cmd+Option+=`/`Cmd+Option+-`, Reduce/Increase Contrast `Cmd+Option+Ctrl+,`/`.`), Mission Control (`Ctrl+UpArrow`, etc.), Spaces (`Ctrl+LeftArrow`/`RightArrow`), and the Color Picker (`Cmd+Shift+C`). -- **Native examples**: Apple's "Keyboard Shortcuts" pane in System Settings is the authoritative list. -- **Fix**: Expand the list. At minimum add: `Cmd+F5` (VoiceOver), `Cmd+Option+8`, `Cmd+Option+Ctrl+,`/`.`, `Ctrl+UpArrow`, `Ctrl+DownArrow`, `Ctrl+LeftArrow`, `Ctrl+RightArrow`, `Cmd+Ctrl+C` (Color Picker - which is currently used by `.switchConnection`). -- **Effort**: S - -### [P1] `Cmd+Option+I` for Inspector overlaps with Safari's "Show Web Inspector" - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:499`. -- **Current**: `.toggleInspector: KeyCombo(key: "i", command: true, option: true)`. -- **HIG says**: `Cmd+Option+I` is Safari Web Inspector. Most users have it bound system-wide via developer tools enable. Inside TablePro this is fine, but be aware. -- **Native examples**: Xcode "Show Inspectors" `Cmd+Option+0`. Pages "Show Inspector" `Cmd+Option+I`. So Apple's apps disagree. -- **Fix**: Hold. `Cmd+Option+I` is acceptable. - -### [P1] "Truncate Table" lives in Edit menu, not in a database-specific menu - -- **File**: `TablePro/TableProApp.swift:451-456`. -- **Current**: "Truncate Table" is a row in the Edit menu's row-operations cluster. -- **HIG says**: Edit menu is for Cut/Copy/Paste/Find/Undo/Redo, not destructive table-level operations. HIG: "Group menu items by the kind of action they perform." -- **Native examples**: TablePlus places destructive ops on the table in a per-table context menu, not in Edit. -- **Fix**: Remove from Edit menu, leave only in the sidebar context menu (where it already lives via `SidebarContextMenu`). Or move to a top-level "Database" menu (see Query-menu split finding). -- **Effort**: S - ---- - -## P2 — Polish, label wording, separators, ellipses - -### [P2] "Manage Connections" missing ellipsis (folded into existing rename to "New Connection...") - -- See P1 entry above. - -### [P2] "Open Database..." ellipsis is correct, but the action does not actually open a file - it opens an in-app sheet - -- **File**: `TablePro/TableProApp.swift:216-220`. -- **Current**: "Open Database..." opens the database switcher sheet. -- **HIG says**: Ellipsis is fine because the user must pick a database. -- **Note**: Confirming. The bigger issue (label wording) is P0. - -### [P2] "Documentation" (in Help menu) lacks any indication that it opens a web URL - -- **File**: `TablePro/TableProApp.swift:588-590`. -- **Current**: "Documentation" with no leading icon, no trailing arrow, no ellipsis. -- **HIG says**: Apple's help-menu items that link out usually use unadorned text. No change needed; just noting that `tablepro.app` and `docs.tablepro.app` open in the browser silently. `NSWorkspace.shared.open(...)` on an `https://` URL is the correct approach. -- **Note**: No change. - -### [P2] "Report an Issue..." ellipsis is correct (opens FeedbackWindowController sheet) - -- **File**: `TablePro/TableProApp.swift:600-602`. -- **Note**: Already correct. - -### [P2] "About TablePro" item bundled with `Check for Updates...` and `MCPServerMenuItem` - -- **File**: `TablePro/TableProApp.swift:145-178`. -- **Current**: `CommandGroup(replacing: .appInfo)` puts About + Check for Updates... + Divider + MCP Status into the App menu. -- **HIG says**: App menu standard order: About App, Settings..., Services, Hide App, Hide Others, Show All, Quit App. "Check for Updates..." is a common third-party addition, placed right after About. The MCP server status item is unusual at this level - it's tool / dev-feature, and should live under Services or its own menu. -- **Native examples**: Sparkle apps: About, Check for Updates..., separator, Settings, Services... TablePro almost matches. -- **Fix**: Keep About + Check for Updates... in the App menu. Move "MCP Server Status" to a non-App-menu location (Help menu, or a new "Developer" menu). -- **Effort**: S - -### [P2] "MCP Server: Running (X clients)" label changes between launches and clutters the App menu - -- **File**: `TablePro/TableProApp.swift:687-712`. -- **Current**: Live-updating MCP server status item in App menu. -- **HIG says**: HIG: "Avoid showing dynamic status in menu titles." -- **Fix**: Move to a status-bar icon (NSStatusItem) or to Settings. Keep a static "Manage MCP Server..." menu entry instead. -- **Effort**: M - -### [P2] "Save as Favorite" appearance in Query menu lacks ellipsis but opens a sheet - -- **File**: `TablePro/TableProApp.swift:358-360`. -- **Current**: "Save as Favorite" - no ellipsis. -- **HIG says**: Action opens a sheet asking for a name. HIG mandates ellipsis. -- **Fix**: Rename to "Save as Favorite...". -- **Effort**: S - -### [P2] "View ER Diagram" lacks parallel structure with "Server Dashboard" - -- **File**: `TablePro/TableProApp.swift:514-522`. -- **Current**: "View ER Diagram" vs "Server Dashboard" (no verb). -- **HIG says**: Sibling menu items should follow the same grammatical pattern. Either both verb-noun or both noun. -- **Native examples**: Xcode View > Show Activity / Show Issues / Show Reports - parallel. -- **Fix**: "Show ER Diagram" / "Show Server Dashboard". Or drop "View" so both are noun-only. -- **Effort**: S - -### [P2] "Open Terminal" has no ellipsis - implies an immediate action, but in some configurations it opens an SSH credential picker - -- **File**: `TablePro/TableProApp.swift:524-528`. -- **Current**: "Open Terminal" - no ellipsis. -- **HIG says**: If the action takes additional input (SSH credentials, host pick), use ellipsis. -- **Fix**: Verify path. If it always opens directly, no change. If it ever requires input, add ellipsis. -- **Effort**: S - -### [P2] Welcome window context-menu "Copy Connection String" / "Copy TablePro Link" / "Copy as JSON" -- inconsistent capitalization - -- **File**: `TablePro/Views/Connection/WelcomeContextMenus.swift:126,134,141`. -- **Current**: "Copy Connection String", "Copy TablePro Link", "Copy as JSON". -- **HIG says**: Title case everywhere. "as" in the middle of "Copy as JSON" is HIG-correct lowercase preposition. Actually fine. The label is fine. -- **Note**: No change. - -### [P2] Welcome window "iCloud Sync" toggle copy: "Include in iCloud Sync" / "Exclude from iCloud Sync" - -- **File**: `TablePro/Views/Connection/WelcomeContextMenus.swift:64-67,170-176`. -- **Current**: Two distinct labels (Include / Exclude) used as a toggle. -- **HIG says**: Toggling labels is correct. But "Include in" / "Exclude from" is wordy. Simpler would be "Sync to iCloud" / "Don't Sync to iCloud" or a direct binary "Sync This Connection". -- **Fix**: Consider tightening, but acceptable. -- **Effort**: S (defer) - -### [P2] Sidebar context menu mixes "Show Structure" with "View ER Diagram" - -- **File**: `TablePro/Views/Sidebar/SidebarContextMenu.swift:80-89`. -- **Current**: "Show Structure" and "View ER Diagram" sit adjacent with no divider. -- **HIG says**: Show vs View - inconsistent verbs. -- **Fix**: Pick one verb. "Show Structure" / "Show ER Diagram". -- **Effort**: S - -### [P2] Sidebar context menu "Create New Subgroup" lacks ellipsis but renames inline (probably correct) - -- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:537-541`. -- **Current**: "New Subgroup" - inline rename in tree. -- **HIG says**: When the new entity is created and immediately ready for inline rename, no ellipsis is appropriate (Finder New Folder). -- **Note**: Correct. - -### [P2] Help menu "TablePro Website" bare URL action lacks an indicator that it's external - -- **File**: `TablePro/TableProApp.swift:584-586`. -- **Current**: "TablePro Website" with no symbol. -- **HIG says**: Apple's Help menu generally uses bare text for external URLs. No HIG violation. -- **Note**: No change. - -### [P2] Edit > Find (`Cmd+F`) has no in-menu indicator that it routes to the editor's Find bar (vs grid search) - -- **File**: `TablePro/TableProApp.swift:430-433`. -- **Current**: `EditorEventRouter.shared.showFindPanelForKeyWindow()` - editor-only. -- **HIG says**: A single Find item that only finds in one of multiple focusable views is ambiguous. Most users hitting `Cmd+F` over the data grid will expect to filter rows. -- **Fix**: Route `Cmd+F` through the responder chain (`performTextFinderAction:`) so the focused view chooses; in the data grid, that should bring up the filter panel. -- **Effort**: M - -### [P2] Editor right-click "Format SQL" has no shortcut shown, even though `.formatQuery` (`Cmd+Shift+L`) is bound - -- **File**: `TablePro/Views/Editor/AIEditorContextMenu.swift:53-62`. -- **Current**: `keyEquivalent: ""` - no key equivalent shown in the context menu. -- **HIG says**: Showing key equivalents in context menus helps users learn the shortcut. -- **Native examples**: Finder context menu "Get Info" shows `Cmd+I`. Mail context menu "Reply" shows `Cmd+R`. -- **Fix**: Set `keyEquivalent` and `keyEquivalentModifierMask` on the NSMenuItem. -- **Effort**: S - -### [P2] Data grid row context menu "Copy" does not show `Cmd+C`, "Paste" does not show `Cmd+V` - -- **File**: `TablePro/Views/Results/TableRowViewWithMenu.swift:39-99`. -- **Current**: All `keyEquivalent: ""` - no shortcuts visible in context menu. -- **HIG says**: Same as above. Useful affordance for keyboard learners. -- **Fix**: Set `keyEquivalent` for items that have a global shortcut. -- **Effort**: S - -### [P2] Terminal context menu shows Copy/Paste with empty `keyEquivalent` - -- **File**: `TablePro/Views/Terminal/TerminalTabContentView.swift:251-261`. -- **Current**: `keyEquivalent: ""`. -- **HIG says**: Same as above. -- **Fix**: Set `keyEquivalent`. -- **Effort**: S - -### [P2] Editor right-click "Save as Favorite..." has ellipsis (correct), but "Format SQL" does not (correct - it's an immediate action). Asymmetry could confuse users - -- **File**: `TablePro/Views/Editor/AIEditorContextMenu.swift:54,66`. -- **Current**: Mixed correctly. -- **Note**: Confirmed correct, no change. - -### [P2] AI right-click "Explain with AI" / "Optimize with AI" no ellipsis - actions stream output to a panel - -- **File**: `TablePro/Views/Editor/AIEditorContextMenu.swift:81,90`. -- **Current**: No ellipsis. -- **HIG says**: Streaming AI is closer to a chat than a dialog. No ellipsis is conventional in modern AI UIs. Borderline. -- **Note**: Hold. - -### [P2] Context menu "Set Value -> NULL / Empty / Default" submenu nests deeper than necessary - -- **File**: `TablePro/Views/Results/TableRowViewWithMenu.swift:135-167`. -- **Current**: Submenu with 1-3 items. -- **HIG says**: HIG: "Avoid one-item or two-item submenus." -- **Fix**: When only 1 entry would be shown (e.g. NOT NULL column with no default), inline as "Set Empty"; when 2+, keep submenu. Or always show all three with appropriate disabled states. -- **Effort**: S - -### [P2] Window menu's "Select Tab N" entries (Cmd+1..9) clutter the menu - -- **File**: `TablePro/TableProApp.swift:546-555`. -- **Current**: Nine permanent menu entries. -- **HIG says**: Apple's native macOS tabs auto-populate the Window menu with tab titles when `tabbingMode = .preferred`. TablePro is adding redundant tab-by-number entries. -- **Native examples**: Safari's Window menu lists tabs by title, not "Select Tab 1". Cmd+1..9 still works via `selectTab(_:)` system-wide. -- **Fix**: Remove the manual "Select Tab N" buttons. Let macOS's native tab handling fire `selectTab:` selectors. The menu becomes self-populating. -- **Effort**: S - -### [P2] No "Move Tab to New Window" / "Merge All Windows" entries - -- **File**: `TablePro/TableProApp.swift:544-578`. -- **Current**: Custom Window menu lacks the standard tab-management entries. -- **HIG says**: Apple's Window menu standard for tabbed apps: Show Previous Tab, Show Next Tab, Move Tab to New Window, Merge All Windows. SwiftUI / NSWindow injects most of these automatically when `tabbingMode = .preferred`. Verify presence. -- **Fix**: Verify with the running app. If missing, add via `NSWindow.moveTabToNewWindow:` and `NSWindow.mergeAllWindows:` selectors. -- **Effort**: S - -### [P2] `Cmd+Shift+P` for "Preview SQL" overlaps with the macOS "Page Setup" chord in document apps - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:468`. -- **Current**: `Cmd+Shift+P`. -- **HIG says**: `Cmd+Shift+P` = "Page Setup..." in print-aware apps. TablePro doesn't print, so safe in scope, but unusual. -- **Fix**: Hold. - -### [P2] `KeyCombo.cleared` sentinel value uses an empty `key` string and is conceptually fragile - -- **File**: `TablePro/Models/UI/KeyboardShortcutModels.swift:519-526`. -- **Current**: Sentinel value with empty key. -- **HIG says**: Not a HIG concern, but worth flagging. A `nil` is more idiomatic. -- **Fix**: Refactor `KeyboardSettings.shortcuts` to `[String: KeyCombo?]` so the absent state is the empty/nil case. -- **Effort**: M - -### [P2] Settings vs Preferences naming - -- **File**: `TablePro/TableProApp.swift:634-637`. -- **Current**: SwiftUI's `Settings { ... }` scene auto-injects "Settings..." in the App menu (macOS 13+). -- **HIG says**: Correct. Apple renamed "Preferences" to "Settings" in macOS 13. -- **Note**: No change. - -### [P2] Custom about panel has links separated by ` | ` — not a HIG style - -- **File**: `TablePro/TableProApp.swift:159-164`. -- **Current**: Uses `" | "` separator between credits links. -- **HIG says**: Apple's about panels (Sparkle apps included) use separate lines or a dedicated credits view. -- **Native examples**: Most Sparkle apps put each link on its own line. -- **Fix**: Stack vertically using `\n` separators in the attributed string. -- **Effort**: S - ---- - -## Summary - -| Severity | Count | Areas covered | -| --- | --- | --- | -| **P0** | 13 | App menu, File menu, View menu, Edit menu, Query menu, key conflicts | -| **P1** | 22 | Wording, customization gaps, menu placement, label parallelism, system-reserved list | -| **P2** | 22 | Polish, ellipses, key equivalents in context menus, About panel | -| **Total** | **57** | | - -By area: - -| Area | P0 | P1 | P2 | -| --- | --- | --- | --- | -| App menu | 0 | 0 | 3 | -| File menu | 5 | 4 | 1 | -| Edit menu | 1 | 4 | 4 | -| View menu | 4 | 3 | 1 | -| Query menu | 2 | 5 | 3 | -| Window menu | 1 | 1 | 2 | -| Help menu | 0 | 2 | 1 | -| Keyboard defaults (`KeyboardShortcutModels.swift`) | 6 | 6 | 1 | -| Context menus (welcome, sidebar, data grid, editor, terminal, results, history) | 0 | 5 | 6 | -| Cross-cutting (settings infrastructure, sentinel) | 0 | 2 | 2 | - -Top-priority fixes (suggest doing first): - -1. Rebind `Cmd+D` to Duplicate (move Save as Favorite to Cmd+Shift+D). -2. Drop the `Cmd+Y` mapping (Quick Look conflict). -3. Drop the `Cmd+Option+Delete` mapping (Empty Trash conflict). -4. Drop the `Cmd+Ctrl+C` mapping (Color Picker conflict). -5. Drop the `Cmd+L` mapping (URL bar conflict; it also collides with `Cmd+Shift+L = Format Query`). -6. Move Refresh, Switch Connection, Quick Switcher out of the Query menu. -7. Toggle Sidebar / Inspector / Filters / History / Results: switch labels Show/Hide. -8. Re-add a real "New Window" item with a non-`Cmd+N` shortcut. -9. Fix label parallelism: "Save Changes" -> "Save", "Toggle X" -> "Show/Hide X", "View ER Diagram" / "Server Dashboard" parallel. -10. Add Find submenu (Find, Find Next, Find Previous, Use Selection for Find). diff --git a/docs/refactor/hig-audit/02-windows-interactions.md b/docs/refactor/hig-audit/02-windows-interactions.md deleted file mode 100644 index 682e3b373..000000000 --- a/docs/refactor/hig-audit/02-windows-interactions.md +++ /dev/null @@ -1,394 +0,0 @@ -# Windows, Tabs, Sheets, and Interactions Audit - -**Agent**: window-interaction-auditor -**Date**: 2026-05-01 -**Scope**: Main `TablePro/` target. AppKit + SwiftUI hybrid. Source: `TablePro/Core/Services/Infrastructure/*`, `TablePro/Views/**`, `TablePro/AppDelegate.swift`, `TablePro/TableProApp.swift`. - ---- - -## P0 — Broken native contracts - -### [P0] Window-modal sheets used for sheets-of-sheets across export, import, AI provider, database switcher - -- **File**: `TablePro/Views/Export/ExportDialog.swift:97`, `:132`, `:146`; `TablePro/Views/Import/ImportDialog.swift:95`, `:103`, `:112`; `TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift:117`, `:120`; `TablePro/Views/Connection/ConnectionFormView+GeneralTab.swift:220`, `:223` -- **Current**: A sheet (`ExportDialog`, `ImportDialog`, `DatabaseSwitcherSheet`, `ConnectionFormView`) presents another sheet on top of itself: `LicenseActivationSheet`, `ExportProgressView`, `ExportSuccessView`, `ImportProgressView`, `ImportSuccessView`, `ImportErrorView`, `CreateDatabaseSheet`, `DropDatabaseSheet`, URL import. SwiftUI does present these stacked, but the result is a sheet presented from a sheet, which is what HIG explicitly tells you not to do. -- **HIG says**: "Avoid presenting a sheet from a sheet. Generally, only one sheet is visible at a time. Avoid letting people open a sheet from within a sheet, because hierarchies of sheets can become confusing." (Sheets — macOS). -- **Native examples**: Mail's compose window swaps inline panels for "send later" / attachment errors. Xcode's project settings push the sub-screens inside the same sheet (NavigationStack). Notes' export uses an NSSavePanel attached to the document, not chained sheets. -- **Fix**: - - Replace stacked progress/success sheets with **inline state inside the parent sheet** (a single sheet that swaps between configure → progress → success/error, like Mail's send sheet). - - For `ExportDialog → LicenseActivationSheet` and `ImportDialog → LicenseActivationSheet`: dismiss the parent sheet first, then present activation. Or push activation as a NavigationStack screen inside the parent. - - For `DatabaseSwitcherSheet → CreateDatabaseSheet / DropDatabaseSheet`: turn these into a NavigationStack push inside the switcher (it already has the room — 420×480), or close the switcher and open the create/drop dialog standalone. -- **Effort**: M (each dialog), L overall (5 dialogs) - -### [P0] QuickSwitcher is a window-modal sheet, but it should be a floating panel - -- **File**: `TablePro/Views/QuickSwitcher/QuickSwitcherView.swift:14-249`, presented from `TablePro/Views/Main/MainContentView.swift:204-213` -- **Current**: `QuickSwitcherSheet` is presented via `.sheet(item: coordinator.activeSheet)` and dims the parent window. It is a Spotlight/Cmd+P-style "go to symbol" search. -- **HIG says**: "Use a popover or a panel for short, focused, transient input. A modal sheet stops everything else." Spotlight, Xcode's "Open Quickly", Safari's Tab Switcher, Notes' "Find Note" are all `NSPanel` or popover, not window-modal sheets. -- **Native examples**: Xcode "Open Quickly" (Cmd+Shift+O) — floats above the window, doesn't dim it, dismisses on focus loss. Spotlight, Raycast. -- **Fix**: Promote to `NSPanel` (`.titled, .nonactivatingPanel`, `.fullSizeContentView`) shown via a small factory, similar to `FeedbackWindowController`. Center over the key window, dismiss on Escape or focus loss. Remove from `ActiveSheet` enum. -- **Effort**: M - -### [P0] QuickSwitcher Cmd+1...Cmd+9 selection is not handled — only opening selected item - -- **File**: `TablePro/Views/QuickSwitcher/QuickSwitcherView.swift:69-82` -- **Current**: Only `.return`, Ctrl+J/N/K/P, and the search field arrow keys move/select. No way to jump directly with Cmd+1...Cmd+9 like Spotlight or VS Code Quick Open. -- **HIG says**: Spotlight-style switchers consistently support Cmd+digit jumps for the top N results. -- **Native examples**: Spotlight, Safari Tab Switcher (Cmd+1...Cmd+9 jumps to that tab), Xcode "Open Quickly". -- **Fix**: Add `.onKeyPress` handlers for digit keys with `.command` modifier that select and open the Nth item. -- **Effort**: S - -### [P0] FeedbackWindowController uses NSPanel, but tied to NSApp.keyWindow lifecycle through `viewModel.captureTargetWindow` - -- **File**: `TablePro/Views/Feedback/FeedbackWindowController.swift:18-64` -- **Current**: `showFeedbackPanel` resolves `NSApp.keyWindow` once at open time and stashes it on `viewModel.captureTargetWindow`. If the user switches windows / closes the original window before submitting feedback, capture target is stale or nil. The panel is a singleton, so opening once-then-switching-windows reuses the old captureTargetWindow. -- **HIG says**: Panels are utility windows whose context can update as the user changes the underlying document or window. They should resolve their target lazily when the action runs (on submit) rather than freezing it at open. -- **Native examples**: Mail's "Report Junk" sheet attaches to the active message window. Xcode's "Report a Bug" reads the front document at submit time. -- **Fix**: Resolve `captureTargetWindow` at submit time, not at open time, by inspecting `NSApp.mainWindow` (or by binding the panel to the front main window via parentWindow). -- **Effort**: S - -### [P0] EditorWindow.performClose collapses last tab to empty state instead of closing window - -- **File**: `TablePro/Core/Services/Infrastructure/TabWindowController.swift:27-36`; `TablePro/Views/Main/MainContentCommandActions.swift:352-373` -- **Current**: Cmd+W on a window with one tab and zero query tabs → window closes. With one window and one or more open query tabs → calls `closeTab()` which clears all tabs and leaves an empty "no tabs" main window. Effectively, Cmd+W twice is required to close a single-tab single-window setup. -- **HIG says**: "Cmd+W closes the focused window. If the window is a tab inside a tabbed window group, Cmd+W closes that tab. If only one tab remains, the window itself closes." Cmd+W must never leave an empty window. -- **Native examples**: Safari, Notes, Xcode — Cmd+W closes the tab; if only one tab remains, the window closes. Cmd+Option+W closes all tabs/windows. -- **Fix**: - - Cmd+W should close the active tab if multiple query tabs exist, otherwise close the window. The existing code path is inverted for the single-tab case. - - Reserve "no tabs visible" as a transient empty state, not a destination Cmd+W can drive the window into. - - On the last window's last tab close, fall through to the standard "show welcome" behavior (already in `AppDelegate.windowWillClose`). -- **Effort**: M - -### [P0] No "Show All Tabs" / Cmd+Shift+\\ support - -- **File**: Menu commands in `TablePro/TableProApp.swift:543-578` -- **Current**: Only Cmd+1...Cmd+9, Cmd+Shift+[, Cmd+Shift+] are wired. There is no menu item or shortcut for `NSWindow.toggleTabOverview(_:)` (Show All Tabs / Cmd+Shift+\\), which Safari/Notes/Finder/Mail all expose. -- **HIG says**: When using native window tabs, the standard "View > Show All Tabs" / Cmd+Shift+\\ binding is part of the contract. Users expect it. -- **Native examples**: Safari, Notes, Finder, Mail — every native tabbed app supports `toggleTabOverview`. -- **Fix**: Add `Button("Show All Tabs")` to `CommandGroup(after: .windowArrangement)` in `AppMenuCommands` that calls `NSApp.sendAction(#selector(NSWindow.toggleTabOverview(_:)), to: nil, from: nil)` with `.keyboardShortcut("\\", modifiers: [.command, .shift])`. -- **Effort**: S - -### [P0] No "Move Tab to New Window" command - -- **File**: Menu commands in `TablePro/TableProApp.swift`, no menu item; `EditorWindow` does not override or expose `moveTabToNewWindow:`. -- **Current**: There is no menu, context menu, or shortcut to break a tab out into its own window. Native window tabs support `NSWindow.moveTabToNewWindow(_:)` but it's not surfaced. -- **HIG says**: "When a window can have tabs, the system adds Move Tab to New Window and Merge All Windows to the Window menu automatically. If you replace the menu, you must reinclude these items." -- **Native examples**: Safari, Finder, Notes — both items appear in Window menu. -- **Fix**: Add to the Window menu: - - "Move Tab to New Window" → `NSWindow.moveTabToNewWindow(_:)` - - "Merge All Windows" → `NSWindow.mergeAllWindows(_:)` - Both actions should validate against `validateUserInterfaceItem(_:)` (only enabled when the key window has siblings or is part of a tab group). -- **Effort**: S - -### [P0] Multiple windows for the same connection silently share toolbar/coordinator state - -- **File**: `TablePro/Core/Services/Infrastructure/WindowManager.swift:23-85`; `TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift:32` -- **Current**: `WindowManager.openTab` adds new windows to a tab group keyed by `tabbingIdentifier(for: connectionId)` (or "com.TablePro.main" if `groupAllConnectionTabs` is on). With "groupAllConnectionTabs" off, each connection gets its own tab group, but separate tab groups for the same connection are possible if a user drags one tab out. Toolbar identifiers use `UUID()` (line 49) so each window has its own toolbar — good — but the coordinator is per-window, and multiple coordinators for the same connection share `DatabaseManager.shared.activeSessions[connectionId]`. There's no documented behavior for "should the same connection live in two windows". -- **HIG says**: A document/connection is the user's mental unit. Multi-window per document is allowed (Pages does it) but each window must look first-class — same toolbar, same data, no surprising side effects. -- **Native examples**: Pages, Notes, Xcode — multi-window per document is consistent; closing one window does not close the document, closing the last window does. -- **Fix**: Decide and document one of the following: - 1. **Single window per connection** (TablePlus model) — block `moveTabToNewWindow:` and gate "Open Connection" to focus an existing window. Simpler. - 2. **Multi-window per connection** (Pages model) — verify all per-window state (filter panel, history panel, change manager) is window-scoped (today some of these are not — `FilterStateManager` is created per coordinator, but `DataChangeManager`'s relationship to the underlying session needs review). - - Either is fine, but pick one and enforce. The current state is "accidentally allowed multi-window". -- **Effort**: L - ---- - -## P1 — Non-idiomatic patterns - -### [P1] Welcome window blocks miniaturize and zoom — unnecessary restriction - -- **File**: `TablePro/Core/Services/Infrastructure/WelcomeWindowFactory.swift:47-49` -- **Current**: `standardWindowButton(.miniaturizeButton)?.isHidden = true`, `standardWindowButton(.zoomButton)?.isHidden = true`, `collectionBehavior.insert(.fullScreenNone)` -- **HIG says**: "Don't disable the standard window buttons unless your window genuinely shouldn't be miniaturized." A welcome window can be miniaturized (Xcode, Pages, Sketch all do). -- **Native examples**: Xcode's welcome window — minimizes, can't be resized, can't fullscreen (those are correct restrictions). Sketch's welcome — same. None hide the close button row entirely. -- **Fix**: Re-enable miniaturize. Keep `.fullScreenNone`. Hiding zoom is acceptable for a fixed-size window, but consider showing it greyed-out instead of hidden (matches Xcode behavior). -- **Effort**: S - -### [P1] Connection form window disables miniaturize and zoom and removes from style mask - -- **File**: `TablePro/Core/Services/Infrastructure/ConnectionFormWindowFactory.swift:52-55` -- **Current**: Sets `miniaturizeButton.isEnabled = false`, `zoomButton.isEnabled = false`, then `styleMask.remove(.miniaturizable)`. The first two lines disable greyed buttons; the third removes the affordance entirely so the button hole disappears. -- **HIG says**: Connection editing is a long-running task — users want to alt-tab away or minimize. -- **Native examples**: Notes new note, Mail compose, System Settings panes — all minimizable. -- **Fix**: Drop both blocks. Allow miniaturize. Allow zoom (the form is resizable already). Add `.fullScreenAuxiliary` so the form opens above a fullscreen main window when triggered from inside it. -- **Effort**: S - -### [P1] FavoriteEditDialog is a sheet but should be a panel - -- **File**: `TablePro/Views/Sidebar/FavoriteEditDialog.swift:62-156`, presented via `.sheet(item:)` from `FavoritesTabView.swift:52` and `MainEditorContentView.swift:95` -- **Current**: A 480-wide form-sheet that includes a TextEditor (160 px tall) plus name/keyword/folder/global. Used both from the sidebar (favorites tab) and the editor (Save as Favorite from the query editor). -- **HIG says**: A sheet is appropriate when the action is tied to a single document. This form is a stand-alone object editor — it could outlive the current window context. Other native object-editor flows (Calendar new event, Reminders detail, Photos info) are panels or popovers. -- **Native examples**: Calendar's New Event panel, Reminders' detail panel. -- **Fix**: Decision call. If the favorite is always tied to the current connection, a sheet is fine. If it's a global object (Global toggle exists at line 109), a panel is better. Lean toward panel since the same dialog is reachable from multiple places. -- **Effort**: M - -### [P1] License activation as a sheet, but reachable from many places — duplicates and stacks - -- **File**: `TablePro/Views/Settings/LicenseActivationSheet.swift`, presented from `SafeModeBadgeView.swift:71`, `ProFeatureGate.swift:28`, `SyncStatusIndicator.swift:33`, `ExportDialog.swift:97`, `ConnectionFormView+GeneralTab.swift:223`, `WelcomeWindowView.swift:91` -- **Current**: Six different presentation sites. If the user has the export sheet open and clicks "Activate License" inside it, the activation sheet stacks on top of the export sheet (which is a sheet on the main window). Same for the connection form. -- **HIG says**: A licensing dialog is an app-level action. It is not tied to any particular document. It belongs in Settings, or as a panel/window reachable from any context, but not as a sheet that stacks. -- **Native examples**: Sparkle's update window is a panel. Affinity, Pixelmator Pro use a panel for license activation. -- **Fix**: Convert `LicenseActivationSheet` to an NSPanel reachable via `LicenseWindowController.shared.show()`. Each of the six call sites simply triggers the controller. -- **Effort**: M - -### [P1] Sheets sized with hard-coded `.frame(width:)` lose the user's resize state - -- **File**: `MaintenanceSheet.swift:75` (`.frame(width: 420)`), `TableOperationDialog.swift:165` (`.frame(width: 320)`), `LicenseActivationSheet.swift:83` (`.frame(width: 400)`), `MCPTokenRevealSheet.swift:40` (`.frame(width: 540, height: 520)`), `DatabaseSwitcherSheet.swift:110` (`.frame(width: 420, height: 480)`), `QuickSwitcherView.swift:59` (`.frame(width: 460, height: 480)`), `FavoriteEditDialog.swift:132` (`.frame(width: 480)`), `AIProviderDetailSheet.swift:91` (`.frame(minWidth: 520, minHeight: 480)`) -- **Current**: Most sheets are fixed-size. Only `AIProviderDetailSheet` allows resize. -- **HIG says**: "Use a sheet that's small enough to fit the content but large enough that people don't have to scroll a lot. If the content can be longer, allow the sheet to grow." Sheets containing a TextEditor (FavoriteEditDialog, MCP token, JSON viewer) should be resizable. -- **Native examples**: Mail's compose, Notes' share sheet — resizable. -- **Fix**: Add `.frame(minWidth:idealWidth:maxWidth:)` and rely on the platform to remember user-set size where the sheet is editor-like (any sheet containing a TextEditor or a long form). -- **Effort**: S per dialog - -### [P1] No drag-out support on data grid rows — only intra-grid reorder - -- **File**: `TablePro/Views/Results/DataGridView+RowActions.swift:175-227` -- **Current**: `pasteboardWriterForRow` writes `com.TablePro.rowDrag` (custom UTI), TSV, and HTML. `validateDrop` rejects any drag whose source is not the same NSTableView (line 203: `info.draggingSource as? NSTableView === tableView`). So you cannot drag selected rows into Numbers, Excel, a text editor, Mail, or Finder. -- **HIG says**: "Where dragging makes sense, support it broadly. Tables and lists usually allow dragging selected rows out of the app." TSV+HTML on the pasteboard would be enough to drag rows into Numbers as a table. -- **Native examples**: Numbers, Finder list view, Mail attachment list — all support drag out. -- **Fix**: Drop the `info.draggingSource as? NSTableView === tableView` check from `validateDrop`. Implement `tableView(_:writeRowsWith:to:)` (or its newer pasteboardWriter equivalent) so that dragging out writes both the custom `rowDrag` type (intra-grid moves), and standard `string` + `html` types (cross-app drops). External apps will see TSV/HTML; internal moves still see the custom type. -- **Effort**: M - -### [P1] No drop-onto-window for SQL files and CSVs - -- **File**: No NSWindow drop target outside `Views/Settings/Plugins/InstalledPluginsView.swift:46` (which accepts `.fileURL` for plugin install) and `Views/Feedback/FeedbackView.swift:122`. -- **Current**: Dragging a `.sql`, `.csv`, or `.json` file onto the main editor window does nothing. The Open command exists, but drag-drop is the macOS norm. -- **HIG says**: "Documents that can be opened by your app should accept drop." Apple's CFBundleDocumentTypes is registered (per the recent commit referenced in git log) but the receiving window doesn't implement drop. -- **Native examples**: Xcode (drag a `.swift` onto the project), TextEdit (drag a `.txt` onto the window), VS Code, Sublime Text. -- **Fix**: Add `.onDrop(of: [.fileURL], isTargeted: nil) { providers in ... }` at the level of the editor's main content view (or implement `NSDraggingDestination` on the `EditorWindow`'s contentView). Route through the same path as File > Open. -- **Effort**: M - -### [P1] Welcome window has no drop target for `.tablepro` import files - -- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift` -- **Current**: Has `.fileImporter` for `.tableproConnectionShare` (line 137-145) but no `onDrop`. Users with a `.tablepro` file in Finder can't drag it onto the welcome window. -- **HIG says**: Anywhere you accept a file via "Import...", you should accept the same file via drop. -- **Native examples**: Apple Music welcome (drag .m4a), iMovie welcome (drag .mp4). -- **Fix**: Add `.onDrop(of: [.tableproConnectionShare], isTargeted: ...)` on the welcome view that routes to the same `vm.activeSheet = .importFile(url)` path. -- **Effort**: S - -### [P1] Multi-select in WelcomeWindowView connection list does not follow Finder selection conventions - -- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:280-326` -- **Current**: Selection is implemented as a `Set` on the SwiftUI `List`. Cmd+A (line 316-320) selects all visible. Cmd+Click and Shift+Click are handled by the `List` natively, so this is OK at first glance — but the connection rows themselves are wrapped in `WelcomeConnectionRow` with custom hit testing. Connect-on-double-click is wired through `DoubleClickDetector` (used elsewhere in `connectionList` rendering). -- **HIG says**: Lists with selection follow Finder/Mail conventions: click selects, Shift+click extends, Cmd+click toggles, double-click activates, Return activates the focused row. -- **Native examples**: Finder, Mail, Notes. -- **Fix**: Verify Shift+Click range selection works against the visible flat list (not just the in-group order). Verify Cmd+Click toggles a single row in/out of the selection without clearing other selections. Add tests if missing. -- **Effort**: S to verify, M if broken - -### [P1] Sidebar table list uses `.contextMenu` only — important destructive actions hidden - -- **File**: `TablePro/Views/Sidebar/SidebarView.swift:197-216`, `SidebarContextMenu.swift:35-` -- **Current**: "Drop View", "Truncate Table" (via `TableOperationDialog`), and others live only in the right-click context menu. There's no menu-bar equivalent, so a user without a right mouse button (or who has never right-clicked the sidebar) won't discover them. -- **HIG says**: "If a context menu has actions not available elsewhere, it's a discoverability problem. Important destructive actions should also live in the menu bar (with a sensible disable rule)." -- **Native examples**: Finder — every "right-click new folder" action is also under File or Edit. Notes — delete note appears in both the context menu and Edit menu. -- **Fix**: Promote drop/truncate/edit-view-definition into the Edit menu under a "Tables" submenu, gated on `actions.hasTableSelection`. -- **Effort**: M - -### [P1] DatabaseSwitcherSheet drop key uses `delete` (forward delete on a Mac Magic Keyboard) instead of backspace - -- **File**: `TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift:145-149` -- **Current**: `.onKeyPress(.delete) { ... initiateDropForSelected() }` — `KeyEquivalent.delete` in SwiftUI maps to the forward-delete key (fn+delete on most Apple keyboards). -- **HIG says**: Lists conventionally use **Backspace** (the regular Delete key, character `\u{7F}`) for "remove selected", not forward Delete. -- **Native examples**: Finder uses Cmd+Delete (backspace); Mail uses Delete (backspace); Music uses Delete (backspace). -- **Fix**: Replace with `.onKeyPress(characters: .init(charactersIn: "\u{7F}\u{08}"), phases: .down) { ... }` matching the pattern already used in `WelcomeWindowView.swift:229` and `:308`. Consider gating on `.command` modifier (Cmd+Delete) since dropping a database is unusually destructive. -- **Effort**: S - -### [P1] `MaintenanceSheet` Execute button uses `.defaultAction` keyboard shortcut for a destructive operation - -- **File**: `TablePro/Views/Sidebar/MaintenanceSheet.swift:66-71` -- **Current**: Execute (which can run `VACUUM FULL` and rewrite a table while blocking access) is bound to Return via `.keyboardShortcut(.defaultAction)`. -- **HIG says**: For destructive defaults, either make Cancel the default action and require the user to click Execute, or attach Return to a non-destructive primary action. Apple's NSAlert convention places Cancel last (right) and emphasized via Escape; destructive primaries are usually distinct from the default. -- **Native examples**: Finder Empty Trash — Empty is on the right but Cancel is highlighted as the default. System Settings Reset — same pattern. -- **Fix**: Mark Cancel as `.keyboardShortcut(.cancelAction)` (already done at `:65`) and remove the `.defaultAction` from Execute. Force a deliberate click to execute, or require holding Option to enable Return-to-execute. Consider also adding `.role(.destructive)` for the right styling. -- **Effort**: S - -### [P1] `TableOperationDialog` Drop button uses `.keyboardShortcut(.return, modifiers: [])` and is destructive default - -- **File**: `TablePro/Views/Sidebar/TableOperationDialog.swift:154-162` -- **Current**: Drop button is destructive but bound to Return as the default action. Cancel has no `.cancelAction`. -- **HIG says**: Same as above — Cancel should be default for destructive sheets, or at minimum no Return shortcut. -- **Native examples**: Finder's "Move to Trash" prompt makes Cancel the default; Trash is on the right. -- **Fix**: Add `.keyboardShortcut(.cancelAction)` to Cancel (line 148), remove the Return shortcut from Drop, mark Drop as `.role(.destructive)`. -- **Effort**: S - -### [P1] AlertHelper destructive confirms put confirm button first (left) and don't use .destructive role - -- **File**: `TablePro/Core/Utilities/UI/AlertHelper.swift:24-39`, `:43-65`, `:130-163` -- **Current**: `confirmDestructive` adds the confirm button first (`addButton(withTitle: confirmButton)`), which puts it on the **right** in macOS 11+ NSAlert layout (NSAlert lays out buttons right-to-left in addition order). Confirm/Execute is on the right, Cancel on the left, but `confirmDestructive` does not call `hasDestructiveAction = true` on the destructive button. `confirmSaveChanges` does set it on Don't Save (line 142), so the pattern is known. -- **HIG says**: Destructive buttons should be on the **leading** (left) side, with Cancel as the default on the right. NSAlert's `hasDestructiveAction = true` puts the button on the left in macOS 11+. -- **Native examples**: Finder's "Move to Trash", Mail's "Delete Message" — destructive action on the left, Cancel on the right (default). -- **Fix**: - - Set `hasDestructiveAction = true` on the destructive button in every confirm helper. - - Audit each call site — many places (`confirmDangerousQueryIfNeeded`, `confirmDiscardChanges`) intend the action button to be destructive. -- **Effort**: S - -### [P1] Settings TabView at fixed 720×500 — lots of content gets clipped on small screens - -- **File**: `TablePro/Views/Settings/SettingsView.swift:64` -- **Current**: `.frame(width: 720, height: 500)` regardless of which tab is selected. -- **HIG says**: Preferences/settings windows should size to their content. Each pane has different needs. -- **Native examples**: System Settings, Xcode Settings, Notes Settings — each pane sizes itself. -- **Fix**: Drop the fixed frame. Add `.frame(minWidth: 600)` and let each tab grow vertically. Or use `Settings` scene with per-tab `idealWidth/idealHeight`. -- **Effort**: S - -### [P1] AIProviderDetailSheet uses NavigationStack inside a sheet — odd nesting - -- **File**: `TablePro/Views/Settings/AIProviderDetailSheet.swift:52-91` -- **Current**: A sheet that wraps its body in `NavigationStack` purely to get a navigation title bar with Cancel/Save toolbar items. Presented from inside the Settings TabView (which is itself a sheet-like preferences window). -- **HIG says**: NavigationStack belongs inside a navigable container. A sheet can have a title via `.navigationTitle` only if the sheet's root is NavigationStack — but a single-screen sheet doesn't need the stack overhead. -- **Native examples**: Mail's compose sheet uses an explicit toolbar instead of NavigationStack. -- **Fix**: Replace the NavigationStack wrapper with a manual header strip (Cancel/title/Save buttons) or with the standard sheet button placement at the bottom. The existing `.toolbar` items will move into a footer HStack. -- **Effort**: S - -### [P1] Cmd+Shift+P (Preview SQL) opens a popover from a Toolbar button — popover anchor is fragile under window resize - -- **File**: `TablePro/Views/Toolbar/TableProToolbarView.swift:154-168` -- **Current**: The Preview SQL toolbar item wraps a Button in a VStack and attaches `.popover(isPresented:)` to the VStack. When the toolbar overflows into the chevron (narrow window), the popover anchor moves to the chevron menu, which can produce a misanchored popover. -- **HIG says**: Popovers should anchor to a stable visible control. A toolbar item can collapse, so the popover must be tolerant. -- **Native examples**: Safari's "Tab Group" toolbar popover uses `NSToolbarItem.itemIdentifier` and a sourceItemIdentifier to anchor correctly. -- **Fix**: Anchor the popover to the toolbar item identifier (NSToolbarItem itemIdentifier) via `popover(isPresented:attachmentAnchor:arrowEdge:)` with a fixed source. Or move the SQL preview into a sheet/panel since the content is rich. -- **Effort**: M - -### [P1] Result tab bar (custom `ResultTabBar`) duplicates native window-tab-bar styling but isn't native - -- **File**: `TablePro/Views/Results/ResultTabBar.swift:11-75` -- **Current**: A custom horizontal scrolling tab bar that visually mimics window tabs. It is for switching between multiple result sets within a single query tab — different conceptual layer than NSWindow tabs. -- **HIG says**: This is fine in principle (it's a sub-tab bar, like Xcode's "Issues / Errors / Warnings" segmented control), but the visual treatment is borrowed from window tabs which can mislead users. Consider using a `Picker(.segmented)` or NSSegmentedControl, both of which are unambiguous. -- **Native examples**: Xcode's debug tab bar uses segmented controls. Numbers/Pages chapter switches use a thinner accent bar. -- **Fix**: Either re-style with `Picker(.segmented)` style or accept a thinner pill design without rounded-rectangle backgrounds. Differentiate from NSWindow tabs. -- **Effort**: M - -### [P1] No "New Window" or "Open in New Window" affordance — only "New Tab" - -- **File**: `TableProApp.swift:204-228` (only "New Tab"), `WindowManager.swift:23-85` -- **Current**: Cmd+T → New Tab. There's no Cmd+N or any other shortcut for "open this connection in a new window separate from the current tab group". Combined with the auto-grouping logic in WindowManager, every connection-open ends up tabbed into an existing group. -- **HIG says**: Apps that support window tabs should also expose "New Window" — Cmd+N is conventional. -- **Native examples**: Safari (Cmd+N new window, Cmd+T new tab), Notes, Finder, Mail. -- **Fix**: Decide based on the multi-window-per-connection decision (see P0 above). If multi-window is allowed, expose Cmd+N as "New Window" (open the same connection without joining the existing tab group) — implementation: temporarily set `tabbingMode = .disallowed` for the new window. If single-window-per-connection is enforced, this finding becomes obsolete. -- **Effort**: M - ---- - -## P2 — Polish - -### [P2] DatabaseSwitcherSheet `current` badge uses lowercase string literal "current" — not localized, capitalization off - -- **File**: `TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift:236` -- **Current**: `Text("current")` — lowercase, in English only. -- **HIG says**: Localized, sentence-case for non-title text. -- **Fix**: `Text(String(localized: "Current"))`. -- **Effort**: S - -### [P2] FavoriteEditDialog "Save" / "Add" buttons need consistent verb - -- **File**: `TablePro/Views/Sidebar/FavoriteEditDialog.swift:124` -- **Current**: Button text alternates between `"Save"` (edit) and `"Add"` (new). Other forms use "Add" or "Create" — inconsistency across the app. -- **HIG says**: Form action verbs should be consistent — pick one of "Save" / "Add" / "Create" / "Done" and use it everywhere. -- **Fix**: Use "Add" for new + "Save" for edit. Cross-check `CreateGroupSheet`, `ConnectionFormView` Save/Add buttons. -- **Effort**: S - -### [P2] DataGrid drag pasteboard writer always sets `string` and `html` even for intra-grid moves - -- **File**: `TablePro/Views/Results/DataGridView+RowActions.swift:175-194` -- **Current**: Every drag carries TSV and HTML on the pasteboard, even though `validateDrop` blocks anything other than `rowDrag`. Wasted serialization on every drag. -- **HIG says**: N/A — this is a perf/correctness polish. -- **Fix**: Only set TSV+HTML when the drag is going to leave the table view (i.e. always, once cross-app drop is allowed — see P1 above). Until then, only set `rowDrag`. -- **Effort**: S - -### [P2] WelcomeWindowView `Frame(minWidth: 350)` on the right panel can clip column headers in narrow mode - -- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:271` -- **Current**: Right panel has `minWidth: 350`. Welcome window itself has `idealWidth: 700`. With 250-px left panel, this can cramp. -- **HIG says**: Resizing should not produce clipped UI. -- **Fix**: Either set a higher `minWidth` on the right panel, or add `idealWidth: 450` plus a higher minimum window width. -- **Effort**: S - -### [P2] `MCPTokenRevealSheet` is a sheet but contains lots of read-only setup info — could be a panel - -- **File**: `TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift:9-41` -- **Current**: 540×520 sheet showing token + setup snippets for three clients. The "this token will not be shown again" warning is critical, but once dismissed, the user often wants to copy the token while reading docs in another app — sheet blocks that. -- **HIG says**: Information that the user might reference while doing something else should not be modal. -- **Fix**: Convert to NSPanel that floats above Settings, allowing the user to alt-tab to a terminal and copy without dismissing. -- **Effort**: M - -### [P2] No I-beam cursor over the SQL editor when the editor isn't focused - -- **File**: `TablePro/Views/Editor/SQLEditorView.swift` (CodeEditSourceEditor handles cursor when focused) -- **Current**: CodeEditSourceEditor sets the I-beam cursor when the text view is the first responder. Before focus, hovering shows the arrow cursor. -- **HIG says**: Text-editable areas should show the I-beam cursor on hover regardless of focus, like every native text view. -- **Native examples**: TextEdit, Mail compose, Xcode editor. -- **Fix**: Verify CodeEditSourceEditor sets `addCursorRect(_, cursor: .iBeam)` in `resetCursorRects()`. If not, override on the wrapping NSView. -- **Effort**: S to verify - -### [P2] Welcome window allows zero-letter search — no clear-search affordance other than Esc - -- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:212-258` -- **Current**: `NativeSearchField` (custom). No visible "x" clear button mentioned — needs verification. -- **HIG says**: Search fields show a clear button when text is present. -- **Fix**: Verify `NativeSearchField` provides the standard clear button (`(searchField.cell as? NSSearchFieldCell)?.cancelButtonCell`). -- **Effort**: S verify - -### [P2] EditorWindow doesn't customize the proxy icon click behavior for unsaved files - -- **File**: `TablePro/Views/Main/Extensions/MainContentView+Setup.swift:206-208`, `:239-240` -- **Current**: `representedURL` is set, `isDocumentEdited` is set — both correct. The "click and drag the proxy icon to copy" works automatically. But Cmd+Click on the proxy icon (which shows the path popup) is blocked when `titleVisibility = .hidden` (set in TabWindowController.swift:84) because there's no visible title to host the proxy icon dropdown. -- **HIG says**: Proxy icon Cmd+Click revealing the path is a long-standing macOS contract. -- **Native examples**: TextEdit, Pages, Numbers — all support proxy icon Cmd+Click. -- **Fix**: Don't set `titleVisibility = .hidden`. Instead, rely on the toolbar's principal item to display content, but keep the title strip visible so the proxy icon shows. Or implement a custom title bar that includes the proxy icon dropdown. -- **Effort**: M - -### [P2] FeedbackWindowController hides miniaturize and zoom buttons - -- **File**: `TablePro/Views/Feedback/FeedbackWindowController.swift:42-43` -- **Current**: Miniaturize and zoom hidden. -- **HIG says**: Feedback panels can be minimized so the user can collect screenshots and return. -- **Fix**: Show miniaturize. Zoom is fine to disable for a fixed-size panel. -- **Effort**: S - -### [P2] Settings tab labels use single-word verbs ("General", "Editor", "AI") but "Integrations" is a long word — squeezes layout - -- **File**: `TablePro/Views/Settings/SettingsView.swift:52-54` -- **Current**: 9 tabs in a TabView at 720 px width. With "Integrations" + "Plugins" + "Account", labels are tight. -- **HIG says**: Settings tab labels should be short. "Integrations" is fine but could be "MCP" if the only sub-feature is the MCP server (verify scope). -- **Fix**: If "Integrations" only covers MCP today, consider renaming to "MCP" or moving to a sub-page in General. -- **Effort**: S - -### [P2] `JSONViewerWindowController` uses raw `NSWindow` size persistence to UserDefaults — works, but doesn't use `setFrameAutosaveName` - -- **File**: `TablePro/Views/Results/JSONViewerWindowController.swift:36-71`, `:127-138` -- **Current**: Custom `UserDefaults` getter/setter for `NSSize`. Doesn't use `window.setFrameAutosaveName(...)`. -- **HIG says**: macOS provides `setFrameAutosaveName` precisely for window-frame persistence — both size and origin. -- **Fix**: Replace with `window.setFrameAutosaveName("JSONViewer")` and let AppKit handle the disk format. -- **Effort**: S - ---- - -## Summary - -| Severity | Count | -|----------|-------| -| P0 | 7 | -| P1 | 18 | -| P2 | 11 | -| **Total** | **36** | - -### Highest-impact fixes (do first) - -1. **Sheet-from-sheet stacking** (multiple P0/P1) — refactor Export, Import, DatabaseSwitcher, ConnectionForm to use inline state or NavigationStack push instead of nested sheets. This is the single biggest user-visible departure from native pattern. -2. **QuickSwitcher → panel** — standalone panel anchored above the key window, dismissed on focus loss. Add Cmd+1...Cmd+9 quick-jump. -3. **Window menu completeness** — add "Show All Tabs (Cmd+Shift+\\)", "Move Tab to New Window", "Merge All Windows". -4. **Cmd+W behavior** — fix the inverted single-tab close logic so Cmd+W never produces an empty window. -5. **Drag-out from data grid** — drop the same-table-only check, write TSV/HTML so rows can be dropped into Numbers/Excel. -6. **Multi-window-per-connection decision** — pick TablePlus model (single window) or Pages model (multi-window first-class) and enforce. -7. **Destructive button conventions** — set `hasDestructiveAction = true` everywhere, default Cancel for destructive prompts, remove `.defaultAction` Return shortcut from Drop / Truncate / Vacuum. -8. **License activation as a panel** — six call sites today, all stacking sheets. One panel, six triggers. - -### Cross-cutting refactors (do these as a group) - -- Sheet sizing: every sheet that contains a TextEditor or long form needs `minWidth/idealWidth/maxWidth` and `minHeight/maxHeight` so the user can resize. -- Backspace vs forward Delete: audit every `.onKeyPress(.delete)` site and replace with the `\u{7F}\u{08}` characters set already used in WelcomeWindowView. -- Drag-and-drop: every "Open" / "Import" surface should also be a drop target. -- Standard window-button visibility: WelcomeWindow, ConnectionForm, FeedbackWindow all hide miniaturize without good reason — re-enable. diff --git a/docs/refactor/hig-audit/03-chrome-visual.md b/docs/refactor/hig-audit/03-chrome-visual.md deleted file mode 100644 index 8cf0bbd77..000000000 --- a/docs/refactor/hig-audit/03-chrome-visual.md +++ /dev/null @@ -1,388 +0,0 @@ -# Chrome & Visual Audit (TablePro target) - -**Scope**: toolbar, sidebar, inspector, controls, typography, color, dark mode, accessibility, iconography. -**Source baseline**: `feat/raycast-integration` @ 2026-05-01. -**Method**: read-only static review of `TablePro/Views/**`, `TablePro/Theme/**`, `TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift`, `MainSplitViewController.swift`, `TabWindowController.swift`. Compared against macOS HIG and stock Apple apps (Finder, Mail, Notes, Xcode, System Settings, Music). - -Findings ordered by severity. Single-line summary table at the end. - ---- - -## P0 — Broken native contract - -### [P0] Hard-coded `font(.system(size: …))` everywhere instead of system text styles -- **File**: 80 occurrences across `TablePro/Views/`. Hottest spots: `Views/RightSidebar/EditableFieldView.swift:94,98,109,118`, `Views/RightSidebar/FieldEditors/JsonEditorView.swift:23,34`, `Views/RightSidebar/FieldEditors/BlobHexEditorView.swift:25,36,56,62`, `Views/Connection/WelcomeWindowView.swift:373,408,517`, `Views/Connection/WelcomeConnectionRow.swift:22,37,48`, `Views/Connection/WelcomeLeftPanel.swift:24`, `Views/Settings/AISettingsView.swift:165`, `Views/Settings/LinkedFoldersSection.swift:132`, `Views/Settings/ThemePreviewCard.swift:53`, `Views/Toolbar/ConnectionSwitcherPopover.swift:213,218,229,238`, `Views/Sidebar/FavoriteRowView.swift:15,27`, `Views/Connection/ConnectionSidebarHeader.swift:95,99,121`. -- **Current**: Sizes are pinned to absolute points (often `.system(size: 9)`, `.system(size: 11)`, `.system(size: 32)`, `.system(size: 24, weight: .semibold)`). Many of these clusters are inspector field labels and badges sized at 9 pt — below the macOS minimum readable size, and they do not scale with the user's system text size or accessibility "Larger Text" pref. -- **HIG says**: "Use system fonts and built-in text styles whenever possible" and "Support Dynamic Type." Prefer semantic styles (`.body`, `.callout`, `.caption`, `.caption2`, `.subheadline`, `.headline`, `.title`, `.title3`) so text scales with the user's preferred reading size and matches the rest of the OS. Direct point sizes are reserved for very narrow cases (e.g. art-directed empty-state hero icons). -- **Native examples**: Mail, Notes, Xcode, Finder all render row text at `.body` / `.subheadline` and headers at `.headline`; nothing in stock chrome uses 9 pt text. -- **Fix**: Replace `.font(.system(size: 9))` etc. with semantic styles. Mapping: `9` → `.caption2`, `10–11` → `.caption`, `12–13` → `.subheadline` or `.callout`, `14–16` → `.body`, `17+` → `.title3`/`.title2`. For badge text use `.caption2.weight(.medium)`. Hero icons in empty states are fine as `.system(size: 32)` only when paired with Apple's own `ContentUnavailableView` (which already uses 32 pt). Audit each call: most can drop the explicit size entirely. -- **Effort**: M - -### [P0] Inspector pane labelled "Inspector" but built as a second sidebar with full custom chrome -- **File**: `TablePro/Core/Services/Infrastructure/MainSplitViewController.swift:133-138`, `TablePro/Views/RightSidebar/UnifiedRightPanelView.swift:30-67`, `TablePro/Views/RightSidebar/RightSidebarView.swift`. -- **Current**: The right pane is correctly created with `NSSplitViewItem(inspectorWithViewController:)` (good — that gives the right vibrancy). But the content view starts with a custom `Picker(.segmented)` "Details / AI Chat" tab strip at the top inside the pane (`UnifiedRightPanelView.swift:33-40`). HIG inspectors place mode pickers in the toolbar above the pane, not inside it. The folder name `Views/RightSidebar/` and the type name `RightSidebarView` also encode the wrong mental model — it is an inspector, not a second sidebar. -- **HIG says**: "Inspectors" — "Place an inspector on the trailing side of the window. People can show or hide an inspector to display additional details about an item." Mode switches for an inspector belong on the inspector toolbar accessory, mirroring Xcode (File / History / Quick Help) and Pages (Format / Document). -- **Native examples**: Xcode inspector tab strip lives in the inspector toolbar accessory above the divider. Pages, Numbers, Keynote put the Format/Document switcher in the toolbar above the inspector pane. Finder's Get Info inspector has no inline mode picker. -- **Fix**: Move the Details / AI Chat segmented control out of `UnifiedRightPanelView.body` and into a `NSToolbarItem` in `MainWindowToolbar.swift` aligned over the inspector pane (use `inspectorTrackingSeparator` and place the picker after it). Rename `Views/RightSidebar/` → `Views/Inspector/`, `RightSidebarView` → `InspectorView`, `UnifiedRightPanelView` → `InspectorContentView`, `RightPanelState` → `InspectorState`. Constants like `com.TablePro.rightPanel.isPresented` (`MainSplitViewController.swift:468`) → `com.TablePro.inspector.isPresented` (use a migration read of the old key one time so user state is preserved). -- **Effort**: M - -### [P0] Welcome window hides title bar and clears window background — non-standard chrome -- **File**: `TablePro/Core/Services/Infrastructure/WelcomeWindowFactory.swift:42-48`. -- **Current**: `styleMask = [.titled, .closable, .fullSizeContentView]`, `titleVisibility = .hidden`, `titlebarAppearsTransparent = true`, `isOpaque = false`, `backgroundColor = .clear`, miniaturize and zoom buttons hidden. The result is a frameless, non-zoomable window — Cmd+M and the green button are gone, and the title bar is invisible to drag/double-click-to-zoom. The view itself draws `.background(.background)` (`WelcomeWindowView.swift:34`), so the transparent NSWindow background is doing nothing useful. -- **HIG says**: macOS windows have standard title bars and the standard traffic-light cluster. Hiding the zoom and minimize buttons is reserved for modal panels (sheets, settings, alerts). A welcome window is a regular window — Apple's stock Welcome to Xcode and Welcome to Numbers both have a normal title bar and the full close-min-zoom triplet (zoom is disabled, not hidden, on Welcome to Xcode). -- **Native examples**: Welcome to Xcode, Welcome to Pages, Welcome to Numbers — standard title bar, full traffic-light triplet. -- **Fix**: Remove `titleVisibility = .hidden`, `titlebarAppearsTransparent = true`, `isOpaque = false`, `backgroundColor = .clear`, and the two `standardWindowButton(_:)?.isHidden = true` lines. Keep `.fullSizeContentView` only if the design genuinely extends content under the title bar; otherwise drop it too. Set a real `window.title` (`String(localized: "Welcome to TablePro")` is already there). -- **Effort**: S - -### [P0] Custom `WelcomeButtonStyle` rolls its own bordered button look -- **File**: `TablePro/Views/Connection/WelcomeLeftPanel.swift:91-107`. -- **Current**: `WelcomeButtonStyle` builds a `RoundedRectangle(cornerRadius: 8)` filled with `Color(nsColor: .controlBackgroundColor)` (or `.quaternaryLabelColor` when pressed), 16/12 padding, leading-aligned. This is a re-implementation of `.bordered` / `.borderedProminent` with non-standard pressed states (controls normally darken, not switch background colors). -- **HIG says**: "Buttons" — use system button styles (`.bordered`, `.borderedProminent`, `.borderless`, `.plain`, `.link`). Stock buttons get pressed-state animation, focus ring, accent color, accessibility behavior, and dark-mode treatment for free. -- **Native examples**: Welcome to Xcode "Create New Project / Clone Repository / Open Existing Project" rows use stock `.bordered` `.controlSize(.large)` buttons. System Settings sidebar entries use stock `NSTableView` selection. -- **Fix**: Delete `WelcomeButtonStyle`. Replace `.buttonStyle(WelcomeButtonStyle())` with `.buttonStyle(.bordered)` `.controlSize(.large)`, leading-align with `.frame(maxWidth: .infinity, alignment: .leading)`. If the design needs the asymmetric padding, file it as a P2 polish ticket — most likely the stock control covers it. -- **Effort**: S - -### [P0] `KeyboardHint` builds custom kbd badges instead of using `Text(verbatim:)` with the system pattern -- **File**: `TablePro/Views/Connection/WelcomeLeftPanel.swift:109-128`. -- **Current**: Wraps "⌘N" inside a `RoundedRectangle(cornerRadius: 3)` filled with `.quaternaryLabelColor`. macOS does not draw keyboard shortcut "kbd" pills anywhere in the system — shortcuts in menus are drawn as plain text in the trailing column, and inline shortcuts in tooltips/help are plain text. -- **HIG says**: "Keyboard shortcuts" — show shortcuts with the standard symbol glyphs, in a regular tooltip, status bar, or as the trailing menu accessory. macOS does not use shortcut badges as decorative chrome. -- **Native examples**: Notes "Pinned ⌘P" banner is plain text. Spotlight footer is plain text. Quick Look footer is plain text. -- **Fix**: Drop the rounded rectangle background. Render as `Text("⌘N").font(.system(.caption, design: .monospaced)).foregroundStyle(.tertiary)` followed by the label. Better: replace the entire bottom strip with the standard Welcome window pattern — a footer line of plain affordances ("Show this window when TablePro starts" toggle is what stock Apple welcome windows use). -- **Effort**: S - -### [P0] `TagBadgeView` renders its own capsule pill in the toolbar -- **File**: `TablePro/Views/Toolbar/TagBadgeView.swift:21-35`. -- **Current**: `Text(name).font(.subheadline.weight(.medium)).padding(8/4).background(Capsule().fill(tag.color.color.opacity(0.2)))`. Lives in the principal toolbar item and competes visually with the connection name and DB version. Capsule chrome is not a system pattern in NSToolbar. -- **HIG says**: "Toolbars" — keep items concise and visually consistent. Use the toolbar's title/subtitle, the principal item, or a `.bordered` button. Decorative tinted pills do not appear in stock toolbars. -- **Native examples**: Xcode toolbar shows scheme + run destination as plain text + chevron. Mail toolbar shows mailbox name as title. None paint a colored pill behind status text. -- **Fix**: For `production`-style emphasis, use the existing principal item's window subtitle (`window.subtitle = …`, already wired in `MainSplitViewController.swift:165`) or a small `Image(systemName:)` glyph. If a colored marker is essential, render a 6 pt `Circle().fill(tag.color)` adjacent to the connection name — that mirrors the Finder tag dot and the connection list dot already used in `ConnectionSwitcherPopover`. -- **Effort**: S - -### [P0] Inspector field rows use Capsule pills with hard-coded systemOrange "truncated" badge -- **File**: `TablePro/Views/RightSidebar/EditableFieldView.swift:108-124`. -- **Current**: Type badge `Text(...).font(.system(size: 9, weight: .medium)).background(.quaternary).clipShape(Capsule())` and a "truncated" badge with `.foregroundStyle(Color(nsColor: .systemOrange))` plus 15% systemOrange background. 9 pt is below readable size and not a HIG style. -- **HIG says**: Use `.caption` / `.caption2` semantic styles. For status indicators that need color, use SF Symbols with semantic foregroundStyle (`.tint`, system colors via SwiftUI not NSColor). -- **Native examples**: Xcode build log uses `Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)` next to plain text. Calendar uses small dots, not pills, for tags. -- **Fix**: Replace `font(.system(size: 9, weight: .medium))` with `.font(.caption2.weight(.medium))`. Replace the orange capsule with a leading `Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)` glyph + plain text. Apply the same treatment to the type badge — drop the capsule and render `.foregroundStyle(.tertiary)` text. -- **Effort**: S - -### [P0] `ConnectionSwitcherPopover` rolls its own keyboard-driven list selection -- **File**: `TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift:46-180`. -- **Current**: Manual `selectedIndex: Int`, manual `listRowBackground(RoundedRectangle.fill(Color(nsColor: .selectedContentBackgroundColor)))` for the focused row, manual onKeyPress handlers for ↑/↓/Enter/Esc/Ctrl-J/Ctrl-K. The List is `.listStyle(.sidebar)` but is being rendered inside a popover, where it does not get sidebar vibrancy. -- **HIG says**: Use the system `List(selection:)` binding for keyboard-driven selection. The system manages focus ring, selection background color (light/dark/high-contrast), full keyboard access, and announces row changes to VoiceOver. -- **Native examples**: Spotlight, Raycast (which mirrors Spotlight), Xcode "Open Quickly", System Settings sidebar all use system list selection — none paint their own selection rectangle. -- **Fix**: Switch to `List(selection: $selectedConnectionId)` + `.onKeyPress(.return) { /* connect */ }`. Remove the `listRowBackground` override and `selectedIndex` state. Use `.listStyle(.inset)` (`.sidebar` is wrong inside a popover). Rows become regular `Button { ... } label: { connectionRow(...) }.buttonStyle(.borderless)`. -- **Effort**: M - -### [P0] `ConnectionSwitcherPopover` uses non-semantic `alternateSelectedControlTextColor` for highlighted-row text -- **File**: `TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift:207, 214, 219, 228, 232, 238, 244`. -- **Current**: Every text/icon inside the row branches on `isHighlighted` and substitutes `Color(nsColor: .alternateSelectedControlTextColor)` for foreground. This works for default selection background but breaks under high-contrast and accent color changes (e.g. system accent set to multicolor or a light accent on dark mode). -- **HIG says**: Let SwiftUI/system handle selected-row foregroundStyle. `List` flips foreground to selected-text automatically when a row is selected. -- **Native examples**: Stock List rows show foreground inversion automatically — no app branches on `isHighlighted` to flip text color. -- **Fix**: Once the manual selection is replaced (see previous finding), drop all `isHighlighted ? Color(nsColor: .alternateSelectedControlTextColor) : .primary/.secondary` branches. Plain `.primary` / `.secondary` will invert correctly under selection. -- **Effort**: S (folds into the previous fix) - -### [P0] No `Customize Toolbar…` menu item even though user customization is allowed -- **File**: `TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift:53` (`allowsUserCustomization = true`); `TablePro/TableProApp.swift` View menu has no Customize Toolbar entry. -- **Current**: `allowsUserCustomization = true` on the NSToolbar, so right-click → Customize Toolbar works. But there is no `View > Customize Toolbar…` menu item, which is the stock entry point users look for. -- **HIG says**: "Toolbars" — when a toolbar is customizable, expose a Customize Toolbar… item in the View menu. -- **Native examples**: Mail, Finder, Safari, Xcode all expose `View > Customize Toolbar…`. -- **Fix**: Add `Button("Customize Toolbar…") { NSApp.sendAction(#selector(NSWindow.runToolbarCustomizationPalette(_:)), to: nil, from: nil) }` to the `CommandGroup(after: .sidebar)` block in `TableProApp.swift:460-541`. No keyboard shortcut (Apple does not assign one). -- **Effort**: S - -### [P0] Toolbar shows icon-only by default but no `displayMode` preference; users cannot switch to icon+label -- **File**: `TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift:52`. -- **Current**: `managedToolbar.displayMode = .iconOnly` — fixed. Combined with `autosavesConfiguration = false` (line 54), users cannot persist a different choice. The customization palette still offers Icon Only / Icon and Text / Text Only, but selections are dropped on next launch. -- **HIG says**: Toolbars must persist user customizations. A toolbar that does not autosave is a hostile pattern — repeated re-customization is required after every relaunch. -- **Native examples**: Mail, Finder, Xcode toolbars all autosave display mode and item arrangement. -- **Fix**: `autosavesConfiguration = true`. Drop the explicit `displayMode = .iconOnly` (use the default which respects user pref) or move it behind a one-time "first run" default applied through UserDefaults. Verify that the per-window-instance unique identifier (`NSToolbar(identifier: "com.TablePro.main.toolbar.\(UUID())")`) is intentional — autosave with a per-instance UUID will not persist; if autosave is desired, switch to a stable identifier and address the tab-group sharing issue noted in the comment at lines 47-49 differently (e.g. a single toolbar config shared by all tab windows is what stock apps do). -- **Effort**: M - -### [P0] Sidebar list uses `.listStyle(.sidebar)` but search field lives outside the sidebar's vibrancy region -- **File**: `TablePro/Views/Sidebar/SidebarView.swift:183-241` (List), search field is in `MainSplitViewController.swift:120` via `SidebarContainerViewController`. -- **Current**: SidebarContainerViewController wraps the SwiftUI List inside an NSSplitViewItem(sidebarWithViewController:), which gives correct sidebar vibrancy. But there is no search field in the sidebar itself — connection-list search lives in the Welcome window. In-sidebar search affordance ("Filter tables") is missing entirely; the only filter input on the inspector field list (`RightSidebarView.swift:220-226`) uses `NativeSearchField` (good). -- **HIG says**: "Sidebars" — for table-of-contents sidebars (Mail mailboxes, Xcode navigators, Finder), a search/filter field at the top of the sidebar is the established pattern when the list can grow long. With dozens-to-hundreds of tables in a typical schema, this is needed. -- **Native examples**: Xcode Project navigator has a filter field at the bottom. Mail has search at the top. Finder doesn't use one for sidebar but Xcode is the closer analogue. -- **Fix**: Add a `NativeSearchField` at the top of the sidebar's List inside `SidebarView.tablesContent`, bound to `viewModel.searchText` (already exists in the view model — only the input field is missing in the in-window sidebar). Match the Welcome window's search field control size. -- **Effort**: S - -### [P0] Settings layout uses `TabView` (legacy macOS 12 pattern), not the modern System Settings sidebar style -- **File**: `TablePro/Views/Settings/SettingsView.swift:18-65`. -- **Current**: `TabView` with 9 `tabItem`s (General, Appearance, Editor, Keyboard, AI, Terminal, Integrations, Plugins, Account). Renders the macOS 12-style horizontal tab strip across the top of the Preferences window. Frame fixed at 720×500. -- **HIG says**: macOS 14+ recommends the System Settings pattern: `NavigationSplitView` with sidebar of categories on the left and the active section on the right, `formStyle(.grouped)`, scrollable content. Apple converted every first-party app to this pattern in the macOS Sonoma cycle. -- **Native examples**: System Settings (Sonoma+), Xcode Settings (15+), Notes Settings, Mail Settings, Reminders Settings — all use sidebar + detail. None ship the horizontal tab bar anymore. -- **Fix**: Convert `SettingsView` to `NavigationSplitView { List(selection: $selectedTab) { … } } detail: { switch selectedTab { … } }`. Each tab becomes a sidebar row with `Label("General", systemImage: "gearshape")`. Drop the fixed 720×500 frame (System Settings windows are resizable). Inner section views already use `Form().formStyle(.grouped)` (see `GeneralSettingsView.swift:30, 95`) so they drop in unchanged. -- **Effort**: M - -### [P0] Settings tabs use `.font(.system(size: 32))` empty-state hero icons across multiple panes -- **File**: `Views/Settings/LicenseActivationSheet.swift:22`, `Views/Settings/Sections/MCPAuditLogView.swift:81`, `Views/Settings/Plugins/InstalledPluginsView.swift:297`, `Views/Settings/Plugins/BrowsePluginsView.swift:232`, `Views/Connection/OnboardingContentView.swift:153`, `Views/Connection/ConnectionImportSheet.swift:64,125`, `Views/Connection/ImportFromApp/ImportFromAppSheet.swift:65`, `Views/DatabaseSwitcher/DropDatabaseSheet.swift:26`. -- **Current**: Multiple settings/sheet panes hand-roll the empty state pattern with hard-coded 32 pt SF Symbols + secondary text + tertiary description. -- **HIG says**: macOS 14+ ships `ContentUnavailableView` (and `ContentUnavailableView.search`). It scales correctly with Dynamic Type, supports the standard description+actions layout, and is what stock apps now use for empty states. -- **Native examples**: Photos "No Photos in Library", Mail "No Mailbox Selected", Notes "No Notes" — all `ContentUnavailableView`. `SidebarView.swift:160-174` already uses `ContentUnavailableView` correctly; settings/sheets did not get the same treatment. -- **Fix**: Replace each ad-hoc empty state with `ContentUnavailableView(label, systemImage:, description:)`. Drop the manual VStack + `.font(.system(size: 32))` pattern. -- **Effort**: M - -### [P0] App appearance picker still custom-rolled instead of using `Picker(.segmented)` -- **File**: `TablePro/Views/Settings/AppearanceSettingsView.swift:60-65`. -- **Current**: Uses `.pickerStyle(.segmented)` which is correct, but at the top of the Appearance pane outside any Form. Modern System Settings puts the Appearance toggle in the General pane via `Picker` with the three-image `Light / Dark / Auto` row inside a Form section — the segmented placement at the top of an HSplitView is non-standard. -- **HIG says**: "Pickers" — group settings inside a `Form` so they pick up the standard inset list look in Settings. -- **Native examples**: System Settings → Appearance: three-image picker in a Form section. -- **Fix**: After `SettingsView` is migrated to `NavigationSplitView` (previous P0), put the appearance picker inside a `Section` of a `Form().formStyle(.grouped)` rather than free-floating above an `HSplitView`. -- **Effort**: S - -### [P0] `.help(...)` strings duplicated as `.accessibilityLabel(...)` on icon-only toolbar/sidebar buttons; many icon-only buttons have NO `.accessibilityLabel` -- **File**: `Views/Toolbar/SafeModeBadgeView.swift:27-28` (good — both set), `Views/Toolbar/TagBadgeView.swift:33-34` (good). `Views/Sidebar/RedisKeyTreeView.swift:84,100` (no accessibilityLabel), `Views/Settings/AISettingsView.swift:126` (no accessibilityLabel), `Views/Settings/LinkedFoldersSection.swift:91` (no accessibilityLabel), `Views/Connection/ConnectionColorPicker.swift:23` (no accessibilityLabel), `Views/Connection/ConnectionTagEditor.swift:199` (no accessibilityLabel), `Views/Results/ResultTabBar.swift:49,60` (no accessibilityLabel), `Views/Connection/WelcomeWindowView.swift:186-210` (good — both set). Toolbar buttons in `MainWindowToolbar.swift` rely solely on `.help(...)` — VoiceOver does NOT read `.help`; it reads `.accessibilityLabel`. -- **Current**: 23 `Image(systemName:)` invocations across `Toolbar/`, `Sidebar/`, `RightSidebar/` plus many more in main views. Buttons using `.buttonStyle(.plain)` with bare `Image` labels render as icons-only and need an explicit `.accessibilityLabel`. -- **HIG says**: "Accessibility" — every interactive element must announce itself to VoiceOver. `.help()` is the tooltip text, not the accessibility label. They are separate properties (and on many SF Symbol-only buttons should match). -- **Native examples**: Mail toolbar buttons (Reply, Forward, Move) all have `accessibilityLabel` — verifiable via VoiceOver in Mail. -- **Fix**: Mechanical pass: every `Button { … } label: { Image(systemName: …) }` or every Label-based icon-only `Button` needs `.accessibilityLabel(String(localized: "…"))`. Where help text already exists, the same string usually works. `MainWindowToolbar.swift` toolbar buttons render via `Label("Refresh", systemImage:)` so SwiftUI synthesizes a label from the title — those are OK. The popovers and inline plain buttons are not. -- **Effort**: M - -### [P0] `Image` glyphs lack `.accessibilityHidden(true)` when label text fully describes the row -- **File**: `Views/Sidebar/FavoriteRowView.swift:14-30` is correct (`.accessibilityHidden(true)` on the star, globe, keyword glyphs and `.accessibilityElement(children: .combine)` on the row). Most other rows do NOT do this, e.g. `Views/Connection/WelcomeConnectionRow.swift:20-50` (no `.accessibilityElement(children: .combine)`, status icons not hidden), `Views/Toolbar/ConnectionSwitcherPopover.swift:206-249`, `Views/Toolbar/ConnectionStatusView.swift:74-83`. -- **Current**: VoiceOver navigates through every glyph in a row separately ("image, image, MySQL Local, image, …") instead of reading the row as a single labelled element. -- **HIG says**: "Accessibility" — combine decorative children into one element with a meaningful label. -- **Native examples**: Mail account rows announce as a single sentence ("iCloud, 17 unread messages, account"). -- **Fix**: Wrap row content in `.accessibilityElement(children: .combine)` and mark decorative glyphs `.accessibilityHidden(true)`. Apply systematically across `WelcomeConnectionRow`, the row builder in `ConnectionSwitcherPopover.connectionRow`, `ConnectionStatusView.databaseNameLabel`, `ConnectionSidebarHeader`. -- **Effort**: M - -### [P0] No respect for `accessibilityReduceMotion` on the welcome window's onboarding transition -- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:23-32`. -- **Current**: `withAnimation(.easeInOut(duration: 0.45)) { vm.showOnboarding = false }` plus `.transition(.move(edge: .leading))` / `.transition(.move(edge: .trailing))`. Always animates regardless of system Reduce Motion preference. -- **HIG says**: "Accessibility — Motion" — respect `Reduce Motion` and either replace slide/move with cross-fade or skip the transition entirely. -- **Native examples**: System Settings, Mail message list animations all check Reduce Motion before doing horizontal slides. -- **Fix**: `@Environment(\.accessibilityReduceMotion) private var reduceMotion` then `withAnimation(reduceMotion ? .easeInOut(duration: 0.15) : .easeInOut(duration: 0.45))`. Or use `.transition(reduceMotion ? .opacity : .move(edge: .leading))`. -- **Effort**: S - ---- - -## P1 — Non-idiomatic - -### [P1] `ConnectionStatusView` ignores the user-selected accent color for tag badges and DB type text -- **File**: `TablePro/Views/Toolbar/ConnectionStatusView.swift:42-83`. -- **Current**: Database info uses `ThemeEngine.shared.colors.toolbar.secondaryTextSwiftUI` rather than `.secondary`. The custom theme system overlays user-defined colors over what should be system semantic colors in chrome. Chrome (toolbar text, sidebar text) should track the system, not a per-theme override — themes are meaningful for the editor and data grid only. -- **HIG says**: Toolbars and sidebars use system label colors (`.primary`, `.secondary`, `.tertiary`, `.quaternary`) so they participate in the user's accent color choice and high-contrast pref. -- **Native examples**: Xcode allows editor themes but its toolbar/sidebar always use system colors. -- **Fix**: Replace `ThemeEngine.shared.colors.toolbar.secondaryTextSwiftUI` with `.secondary` and `tertiaryTextSwiftUI` with `.tertiary`. Drop the `ToolbarThemeColors` struct from `ThemeColors.swift:387-407` once unreferenced. Restrict `ThemeEngine` to editor + data grid colors. -- **Effort**: S - -### [P1] `ExecutionIndicatorView` shows "--" placeholder text in toolbar when no query has run -- **File**: `TablePro/Views/Toolbar/ExecutionIndicatorView.swift:57-63`. -- **Current**: Static "--" rendered when `lastDuration == nil`, taking up width permanently. -- **HIG says**: Toolbars should not display empty placeholder content. If there's nothing to show, the item should be hidden or absent. -- **Native examples**: Xcode's progress indicator only appears during a build. -- **Fix**: Render `EmptyView()` (or simply `nil`) when `!isExecuting && lastDuration == nil && lastClickHouseProgress == nil`. The toolbar item width adjusts naturally. -- **Effort**: S - -### [P1] `Form` pickers in settings re-declare `.pickerStyle(.menu)` instead of inheriting Form's default -- **File**: `Views/Settings/AISettingsView.swift:106, 262`. -- **Current**: Explicit `.pickerStyle(.menu)` set on Pickers inside a `Form().formStyle(.grouped)`. `.grouped` Forms already render Pickers as menu-style — the override is a no-op now and could mismatch Apple's Settings updates later. -- **HIG says**: Use Form defaults; let the form pick the right control style for the current style and platform. -- **Fix**: Remove `.pickerStyle(.menu)` calls inside `formStyle(.grouped)` Forms. Only override when the visual is intentionally different (e.g. `.segmented` for an inline 2-3-option binary choice). -- **Effort**: S - -### [P1] `.font(.system(.subheadline, design: .monospaced))` mixed with semantic styles in toolbar -- **File**: `Views/Toolbar/ConnectionStatusView.swift:44`, `Views/Toolbar/ExecutionIndicatorView.swift:27, 31, 45, 51, 59`. -- **Current**: Database type/version + execution time use `.system(.subheadline, design: .monospaced)`. The tabular-figures-via-monospace approach is fine for changing numbers (execution duration) but applying it to "MySQL 8.0.35" in `ConnectionStatusView` looks like a debug HUD, not toolbar text. -- **HIG says**: Toolbars use system font with proportional digits unless live-updating numeric values benefit from monospaced digits. Use `.monospacedDigit()` for numbers, full `.monospaced` only for code-like content. -- **Native examples**: Xcode shows scheme name in proportional font, build progress numbers in monospaced digits via `.monospacedDigit()`. -- **Fix**: `ConnectionStatusView.databaseInfoSection`: drop `.monospaced`, use plain `.subheadline`. `ExecutionIndicatorView`: replace `.system(.subheadline, design: .monospaced).weight(.regular)` with `.subheadline.monospacedDigit()`. -- **Effort**: S - -### [P1] Hard-coded sidebar / inspector min/max widths -- **File**: `TablePro/Core/Services/Infrastructure/MainSplitViewController.swift:122-138`. -- **Current**: `sidebarSplitItem.minimumThickness = 280`, `maximumThickness = 600`. `inspectorSplitItem.minimumThickness = 270`, `maximumThickness = 400`. The 280 sidebar minimum is large — Mail's mailbox sidebar can compress to 150 and Xcode's to 180. With long table names this is fine, but on smaller windows it eats too much detail width. -- **HIG says**: "Sidebars" — minimum widths around 150-200 are typical; 280 is unusually large. -- **Fix**: Drop minimums to 200/220 and let the user resize. Inspector minimum 270 is reasonable for the field editors but reconsider after the field-row design pass. -- **Effort**: S - -### [P1] Tag color badges in `WelcomeConnectionRow` use opacity-tinted rounded rectangle instead of system tag chip -- **File**: `TablePro/Views/Connection/WelcomeConnectionRow.swift:35-44`. -- **Current**: 9 pt text in a `RoundedRectangle(cornerRadius: 4).fill(tag.color.color.opacity(0.15))`. Both the size and the styling violate HIG. -- **HIG says**: For inline metadata in a list row, use semantic foregroundStyle (just colored text) or a 6-8 pt `Circle().fill(tag.color)` like Finder tags. -- **Native examples**: Finder tag dots in list view, Xcode's color labels in the source navigator (small color indicator + plain text label). -- **Fix**: Drop the rectangle background. Render as `HStack(spacing: 4) { Circle().fill(tag.color.color).frame(width: 6, height: 6); Text(tag.name).font(.caption).foregroundStyle(.secondary) }`. -- **Effort**: S - -### [P1] `ConnectionSidebarHeader` button-as-menu uses bespoke chevron and 16 pt icon -- **File**: `TablePro/Views/Connection/ConnectionSidebarHeader.swift:89-129`. -- **Current**: A custom button-shaped row with `Image(systemName: "chevron.down").font(.system(size: 9, weight: .medium))`, `.buttonStyle(.plain)`, no `MenuStyle`. Behaves like `Menu` content but does not render as one. -- **HIG says**: Use `Menu { ... } label: { ... }` for popup menus. macOS draws the standard menu chevron and applies the proper press / hover states. -- **Native examples**: Mail "All Inboxes" header is a regular static label. Xcode scheme picker is `Menu` with default chrome. -- **Fix**: This view is currently unused (sidebar uses search/list, not a connection header) — verify with grep and delete. If kept, replace the custom HStack with `Menu { /* options */ } label: { /* current label */ }.menuStyle(.button).buttonStyle(.borderless)` and drop the manual chevron. -- **Effort**: S - -### [P1] Theme system has parallel "ToolbarThemeColors" and "SidebarThemeColors" that mostly fall through to system colors -- **File**: `TablePro/Theme/ThemeColors.swift:349-407`. -- **Current**: `ToolbarThemeColors` and `SidebarThemeColors` exist but every field is optional and the resolved values fall through to `nil` (i.e. system semantic colors) for the bundled themes. Adds unnecessary surface area for chrome theming, which the app neither documents nor exposes in Settings. -- **HIG says**: Chrome should track the system. Theming the chrome is a power-user feature that needs a high-contrast and dark-mode test matrix. -- **Fix**: Remove `ToolbarThemeColors` and `SidebarThemeColors` from `ThemeColors.swift` and from any `ResolvedThemeColors` plumbing. Audit the editor theme JSON schema (used by the plugin registry — `PluginManager+Registration.swift:259`) and bump the schema version. -- **Effort**: S - -### [P1] Inspector `Section` headers shout in ALL CAPS -- **File**: `TablePro/Views/RightSidebar/RightSidebarView.swift:78, 89, 102, 115`. -- **Current**: `Text("SIZE")`, `Text("STATISTICS")`, `Text("METADATA")`, `Text("TIMESTAMPS")` literal uppercase strings. SwiftUI `Form().formStyle(.grouped)` and `Section` already render headers in the system uppercase styling for grouped Form sections — by passing pre-uppercased strings the result is a double-uppercase title (CSS-style) on macOS Sonoma+. -- **HIG says**: Provide section titles in normal case and let the platform style apply. macOS 14 grouped Forms use small caps with system tracking. -- **Native examples**: System Settings / Network / Wi-Fi / Other Networks shows "Other Networks" in normal case; the platform applies the small-caps treatment. -- **Fix**: `Text("Size")`, `Text("Statistics")`, `Text("Metadata")`, `Text("Timestamps")`. Run through `String(localized:)`. -- **Effort**: S - -### [P1] `ConnectionSwitcherPopover` headers use ALL CAPS too -- **File**: `TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift:78, 111`. -- **Current**: `Text("ACTIVE CONNECTIONS")`, `Text("SAVED CONNECTIONS")`. Same issue as the inspector headers. -- **Fix**: Use natural case ("Active Connections", "Saved Connections"); when the popover is hosted in a List, the system applies the appropriate header treatment. -- **Effort**: S - -### [P1] Onboarding hero icon at 48 pt; standard is 32 pt or via SF Symbol Hierarchical/Multicolor at the system text scale -- **File**: `TablePro/Views/Connection/OnboardingContentView.swift:153`. -- **Current**: `.font(.system(size: 48))` for hero icon. -- **HIG says**: Hero icons in welcome / onboarding views in stock Apple apps sit around 32-40 pt and use `Image(systemName:).symbolRenderingMode(.hierarchical)` for visual depth. -- **Native examples**: Welcome to Xcode hero is roughly 32 pt; System Settings sidebar avatar is 28 pt. -- **Fix**: Drop to 36 pt + `.symbolRenderingMode(.hierarchical)` plus accent color tinting via `.foregroundStyle(.tint)`. -- **Effort**: S - -### [P1] Filter / Columns toggle in status bar uses `.toggleStyle(.button)` with internal HStack content (HIG anti-pattern) -- **File**: `TablePro/Views/Main/Child/MainStatusBarView.swift:165-183`. -- **Current**: A `Toggle(isOn: ...) { HStack { Image; Text("Filters"); Text("(count)") } }.toggleStyle(.button).controlSize(.small)`. The Toggle binding is a fake (the setter ignores the new value and calls `.toggle()` instead) — that's a code smell and a sign the control should be a regular `Button`. -- **HIG says**: `Toggle(.button)` is for a binary state. When the action is "open / close panel", a normal `Button` with `.bordered` and an active-state visual is more honest. -- **Native examples**: Xcode's Filter, Issues, Tests buttons in nav bars are plain buttons with active highlight. -- **Fix**: Replace with `Button { filterStateManager.toggle() } label: { Label("Filters", systemImage: ...) }.buttonStyle(.bordered).tint(filterStateManager.isVisible ? .accentColor : nil).controlSize(.small)`. -- **Effort**: S - -### [P1] Sidebar tag icon hard-codes `.foregroundStyle(.yellow)` and `.foregroundStyle(.pink)` for branding -- **File**: `TablePro/Views/Connection/WelcomeLeftPanel.swift:58` (`.pink`), `TablePro/Views/RightSidebar/EditableFieldView.swift:95` (`.yellow`). -- **Current**: Direct color literals (`.yellow`, `.pink`) bypass the system semantic palette. -- **HIG says**: Use `Color(nsColor: .systemYellow)`, `Color(nsColor: .systemPink)` so colors track the system accent and high-contrast preferences. -- **Native examples**: Sponsor / heart icons in App Store use `.tint` or `Color(nsColor: .systemPink)`. -- **Fix**: `.foregroundStyle(Color(nsColor: .systemYellow))`, `.foregroundStyle(Color(nsColor: .systemPink))`. -- **Effort**: S - -### [P1] `.systemOrange.opacity(0.15)` background on truncated badge — system colors are not designed for opacity backgrounds -- **File**: `TablePro/Views/RightSidebar/EditableFieldView.swift:122`. -- **Current**: `.background(Color(nsColor: .systemOrange).opacity(0.15))` — system colors aren't intended to be tinted at low alpha; the result will not match a real status banner. -- **HIG says**: Use `.regularMaterial` or `.thinMaterial` plus a colored stroke / colored foreground, not opacity-tinted system colors. -- **Fix**: Drop the background entirely (foreground SF Symbol + plain text is enough), or use `.background(.thinMaterial)` and color the icon only. -- **Effort**: S - -### [P1] Tab strip in inspector (Details / AI Chat) uses `Picker(.segmented)` rather than `inspectorMode` toolbar items -- **File**: `TablePro/Views/RightSidebar/UnifiedRightPanelView.swift:33-40`. -- **Current**: Inline segmented picker. (See P0 above for placement; this P1 covers control choice if placement stays.) -- **HIG says**: For 2-3 mode switches inside an inspector, a SF Symbol-based segmented picker is fine, but it should sit in the toolbar accessory above the inspector (Pages/Numbers pattern). If kept inline, prefer `Picker(...).pickerStyle(.segmented).labelsHidden().controlSize(.small)`. -- **Fix**: After moving to the toolbar (P0 fix), keep `.pickerStyle(.segmented)` since stock toolbar accessories also use it. -- **Effort**: folded into P0 - -### [P1] No `accessibilityHint` on icon-only toolbar buttons whose action is non-obvious -- **File**: `MainWindowToolbar.swift:340-350` (Quick Switcher), `:374-388` (Filters), `:393-407` (Preview SQL), `:411-430` (Results), `:448-459` (History). -- **Current**: `.help(...)` is set but no `.accessibilityHint`. For a button labelled "Filters" via SwiftUI Label, VoiceOver speaks "Filters, button" with no indication of what activating it does. -- **HIG says**: "Accessibility" — when a button's effect is not obvious from its label, add a hint that completes the sentence "Activates this control to..." -- **Fix**: Add `.accessibilityHint(String(localized: "Toggles the filter panel"))` etc. to each non-obvious icon button. -- **Effort**: S - -### [P1] `Color.accentColor.opacity(0.4)` shadow on Welcome app icon -- **File**: `TablePro/Views/Connection/WelcomeLeftPanel.swift:20`. -- **Current**: `.shadow(color: Color.accentColor.opacity(0.4), radius: 20, x: 0, y: 0)` — branded glow effect. -- **HIG says**: Welcome window app icons in stock Apple apps do not have colored glow shadows. Drop shadows are reserved for elevation cues, not branding. -- **Native examples**: Welcome to Xcode app icon — no shadow. -- **Fix**: Remove the shadow modifier. If a subtle elevation is desired, use `.shadow(color: .black.opacity(0.15), radius: 6, y: 2)`. -- **Effort**: S - ---- - -## P2 — Polish - -### [P2] `font(.system(size: 9))` on the Pro/badge pill in Welcome screen content rows -- **File**: `TablePro/Views/Connection/WelcomeWindowView.swift:373` and elsewhere. -- **Current**: 9 pt is below readable size; tag/badge subscripts. -- **Fix**: Use `.caption2` (11 pt at default scale) and trust system text styles. -- **Effort**: S - -### [P2] `Color(red:)` / `Color(.sRGB:)` literals — none found in `TablePro/Views/` -- **Status**: confirmed clean by grep; finding kept here as a positive note in the audit report. Editor theme stores hex strings in JSON (`ThemeColors.swift:20-29` etc.) which is acceptable since editor colors are user-customizable theme content, not app chrome. Keep `SQLEditorTheme` as the single source of truth for editor colors and ensure no leakage outside the editor. -- **Action**: none. - -### [P2] Tooltip `.help()` strings inline keyboard shortcuts in the tooltip text -- **File**: `MainWindowToolbar.swift:269, 290, 309, 324, 345, 371, 386, 402, 426, 443, 457, 471, 487`. -- **Current**: e.g. `.help(String(localized: "Save Changes (⌘S)"))`. macOS automatically shows shortcuts in tooltips when the matching menu item exists with a keyboardShortcut — duplicating the shortcut in the help text causes a double-display. -- **HIG says**: Let the system render shortcuts; help text should be the action description only. -- **Fix**: Verify whether double-display occurs after the menu integration; if so, drop the inline " (⌘S)" suffixes. Otherwise leave but make sure the shortcut symbol matches the actual keyboard binding (which is user-customizable, so a hard-coded string can drift). -- **Effort**: S - -### [P2] Onboarding/welcome `Spacer().frame(height: 48)` magic spacers -- **File**: `TablePro/Views/Connection/WelcomeLeftPanel.swift:46`. -- **Current**: Hard-coded vertical spacer to compose the layout. Magic numbers without origin in design tokens. -- **Fix**: Replace with VStack alignment + dynamic spacing or extract to a `Spacing` constant if this pattern repeats. -- **Effort**: S - -### [P2] Missing `Image.symbolRenderingMode(.hierarchical)` on inspector / sidebar icons -- **File**: `Views/Sidebar/SidebarView.swift:148`, `Views/RightSidebar/RightSidebarView.swift:148, 164`. -- **Current**: SF Symbols render flat — no hierarchical depth. -- **HIG says**: SF Symbols offer monochrome, hierarchical, palette, and multicolor rendering modes. Hierarchical adds subtle visual hierarchy that stock apps consistently apply to status icons. -- **Fix**: `Image(systemName: ...).symbolRenderingMode(.hierarchical)` on status / non-glyph-button icons. -- **Effort**: S - -### [P2] `ContentUnavailableView` not used for inspector empty state — uses ad-hoc VStack -- **File**: `TablePro/Views/RightSidebar/RightSidebarView.swift:54-61`. -- **Current**: Already uses `ContentUnavailableView` (good!) but the icon "sidebar.right" isn't a great match — for an inspector that hosts row details / table info, `tablecells.badge.ellipsis` or `info.circle` is more semantic. -- **Fix**: Replace `systemImage: "sidebar.right"` with a more representative icon. -- **Effort**: S - -### [P2] Settings tab order -- **File**: `TablePro/Views/Settings/SettingsView.swift:18-65`. -- **Current**: Order is General, Appearance, Editor, Keyboard, AI, Terminal, Integrations, Plugins, Account. Modern System Settings groups (1) account/identity at top, (2) appearance/general, (3) feature panes, (4) plugins/extensions. -- **Fix**: After the NavigationSplitView migration, reorder to General, Account, Appearance, Editor, Keyboard, Terminal, AI, Integrations, Plugins. (Account near top is the macOS norm.) -- **Effort**: S - ---- - -## Summary - -| ID | Severity | Area | Title | Effort | -|----|----------|------|-------|--------| -| CV-01 | P0 | Typography | Hard-coded font sizes; switch to semantic styles | M | -| CV-02 | P0 | Inspector | Mode picker inline; rename "RightSidebar" → "Inspector" | M | -| CV-03 | P0 | Welcome window | Restore standard title bar + traffic-light triplet | S | -| CV-04 | P0 | Controls | Drop `WelcomeButtonStyle`, use `.bordered` | S | -| CV-05 | P0 | Welcome | Drop `KeyboardHint` rounded-rect badges | S | -| CV-06 | P0 | Toolbar | Drop `TagBadgeView` capsule chrome | S | -| CV-07 | P0 | Inspector | Drop capsule pills, fix systemOrange badge | S | -| CV-08 | P0 | Popover | Use system List selection in `ConnectionSwitcherPopover` | M | -| CV-09 | P0 | Popover | Drop `alternateSelectedControlTextColor` color flips | S | -| CV-10 | P0 | Toolbar | Add `View > Customize Toolbar…` menu item | S | -| CV-11 | P0 | Toolbar | Enable `autosavesConfiguration`; fix per-instance UUID | M | -| CV-12 | P0 | Sidebar | Add filter search field above tables list | S | -| CV-13 | P0 | Settings | Migrate `TabView` Settings to `NavigationSplitView` | M | -| CV-14 | P0 | Empty states | Replace 32 pt hero VStacks with `ContentUnavailableView` | M | -| CV-15 | P0 | Settings | Move appearance picker into a Form section | S | -| CV-16 | P0 | Accessibility | Add missing `.accessibilityLabel` to icon-only buttons | M | -| CV-17 | P0 | Accessibility | Combine row children + hide decorative glyphs | M | -| CV-18 | P0 | Accessibility | Respect `accessibilityReduceMotion` in welcome transition | S | -| CV-19 | P1 | Color | Drop `ThemeEngine.toolbar` colors; chrome tracks system | S | -| CV-20 | P1 | Toolbar | Hide ExecutionIndicator placeholder when idle | S | -| CV-21 | P1 | Settings | Drop redundant `.pickerStyle(.menu)` inside `.grouped` Form | S | -| CV-22 | P1 | Typography | Use `.monospacedDigit()`, not `.monospaced`, for numbers | S | -| CV-23 | P1 | Sidebar | Reduce sidebar minimum width 280 → 200 | S | -| CV-24 | P1 | Welcome | Tag chip → tag dot in `WelcomeConnectionRow` | S | -| CV-25 | P1 | Sidebar | Replace `ConnectionSidebarHeader` custom button-as-menu with `Menu` (or delete if unused) | S | -| CV-26 | P1 | Theme | Remove `ToolbarThemeColors` / `SidebarThemeColors` from theme schema | S | -| CV-27 | P1 | Inspector | Section titles in normal case, not ALL CAPS | S | -| CV-28 | P1 | Popover | Section titles in normal case in `ConnectionSwitcherPopover` | S | -| CV-29 | P1 | Onboarding | Reduce hero icon from 48 pt to 36 pt + hierarchical | S | -| CV-30 | P1 | Status bar | Replace fake-Toggle filter button with real `Button` | S | -| CV-31 | P1 | Color | Replace `.yellow` / `.pink` literals with `Color(nsColor: .systemYellow/Pink)` | S | -| CV-32 | P1 | Inspector | Drop opacity-tinted systemOrange badge background | S | -| CV-33 | P1 | Accessibility | Add `accessibilityHint` to non-obvious icon buttons | S | -| CV-34 | P1 | Welcome | Drop accent-colored shadow on app icon | S | -| CV-35 | P2 | Typography | Replace 9 pt badge text with `.caption2` | S | -| CV-36 | P2 | Tooltip | Drop inline shortcut suffix from `.help()` strings | S | -| CV-37 | P2 | Welcome | Replace magic `Spacer().frame(height: 48)` | S | -| CV-38 | P2 | SF Symbols | Apply `.symbolRenderingMode(.hierarchical)` to status icons | S | -| CV-39 | P2 | Inspector | Replace `sidebar.right` empty-state icon with semantic glyph | S | -| CV-40 | P2 | Settings | Reorder tabs (Account near top) | S | - -**Counts**: P0 = 18, P1 = 16, P2 = 6, total 40 actionable items. - -The two largest themes are: -1. **Custom chrome where standard exists** — capsules, pills, custom buttons, custom selection backgrounds, custom kbd badges. The fix is mechanical removal in favor of `.bordered`, `.borderless`, semantic colors, and system-managed selection. -2. **Hard-coded typography** — 80 sites of `.font(.system(size: …))`, none of which scale with Dynamic Type. The fix is a pass replacing each with the closest semantic style. - -Both unblock the deeper inspector / settings / welcome HIG migrations. diff --git a/docs/refactor/hig-audit/04-system-document.md b/docs/refactor/hig-audit/04-system-document.md deleted file mode 100644 index 87c7a3c9b..000000000 --- a/docs/refactor/hig-audit/04-system-document.md +++ /dev/null @@ -1,238 +0,0 @@ -# 04 — System & Document Model Audit - -**Agent**: system-document-auditor -**Scope**: `TablePro/` target only. Document model, file associations, Open/Save panels, Undo/Redo, Find/Replace, Settings, About, Services, Notifications, Sparkle, Dock, sandbox/entitlements, Quick Look, localization. -**Date**: 2026-05-01 -**Conclusion**: TablePro hand-rolls a partial document model on top of `QueryTab` and `NSWindow.representedURL`/`isDocumentEdited`. Core Apple infrastructure for documents is missing: no `NSDocument`/`FileDocument`, no Open Recent menu, no auto-save, no Versions, no Revert/Duplicate/Rename/Move To, no Quick Look, no Services. The bridge that does exist works, but every feature Apple gives you for free has to be reimplemented or ignored. - ---- - -## P0 — Broken native contracts - -### [P0] No document model — SQL files are not first-class documents -- **File**: `TablePro/TableProApp.swift:628`, `TablePro/Models/Query/QueryTabState.swift:257`, `TablePro/Core/Services/Infrastructure/SQLFileService.swift:1` -- **Current**: SQL files are loaded into `TabQueryContent.query` (a plain `String`) tracked by `sourceFileURL` + `savedFileContent`. The dirty state is computed by string comparison at `QueryTabState.swift:266`. There is no `NSDocument`, no `FileDocument`/`ReferenceFileDocument`, no `DocumentGroup`. `TableProApp.body` declares only a `Settings { }` scene; main windows are AppKit-imperative. Search confirms zero usages of `NSDocument`/`FileDocument`/`DocumentGroup` (only one comment mention at `AlertHelper.swift:139`). -- **HIG says**: "Documents — Use the document architecture so people can save, open, rename, move, duplicate, revert, and version their files using the same controls they use in every other macOS app." Apple's File menu items (Save, Save As, Duplicate, Rename, Move To, Revert To, Save All, Open Recent) come for free with `NSDocumentController` and `NSDocument`. Hand-rolled document tracking is the canonical reason apps fail HIG review. -- **Native examples**: TextEdit (`NSDocument`), Xcode (`SourceCodeDocument`), Pages, Numbers, Keynote, BBEdit, Nova, Tot. -- **Fix**: Introduce a `SQLDocument: ReferenceFileDocument` (reference-typed because `QueryTab` is mutable and shared with the data grid). Migrate the SQL-file lifecycle out of `MainContentCommandActions.openSQLFile()` / `saveFileAs()` / `saveFileToSourceURL()` / the `.openSQLFiles` Notification, and let SwiftUI's `DocumentGroup` (or a parallel AppKit `NSDocumentController` registration) own Open / Save / Save As / Revert / Duplicate / Rename / Move To / Open Recent for `.sql` files. Connection-bound query tabs that aren't backed by a file remain `QueryTab` only — the document layer wraps the file-bound tabs. -- **Effort**: L - -### [P0] Open Recent menu is missing -- **File**: `TablePro/TableProApp.swift:197-293` (File menu uses `CommandGroup(replacing: .newItem)` and `CommandGroup(after: .newItem)`), `TablePro/Core/Services/Infrastructure/TabRouter.swift:322` (`openSQLFile` never registers recents). -- **Current**: No "Open Recent ▶" submenu anywhere in the File menu. `noteNewRecentDocumentURL` is never called in the entire codebase. SQL files opened from the open panel, drag-drop, Finder Open With, and `tablepro://` URLs never appear in Open Recent. -- **HIG says**: "Open Recent — Most apps that work with documents should provide an Open Recent menu" (HIG → File management). The menu is auto-populated when the app uses `NSDocumentController`, or by calling `NSDocumentController.shared.noteNewRecentDocumentURL(_:)` on every successful open. macOS adds the standard "Clear Menu" item automatically. -- **Native examples**: TextEdit, Xcode, Preview, Pages, BBEdit. Every native document-based app on macOS. -- **Fix**: After moving to `NSDocument`/`ReferenceFileDocument` (P0 above) this comes for free. Until then, call `NSDocumentController.shared.noteNewRecentDocumentURL(url)` from `TabRouter.openSQLFile(_:)`, the SQL `NSOpenPanel` callback in `MainContentCommandActions.openSQLFile()`, and `application(_:open:)` in `AppDelegate.swift:17`. Add `CommandGroup(after: .newItem) { OpenRecentMenu() }` (custom subview that reads `NSDocumentController.shared.recentDocumentURLs`) so the menu actually renders in the SwiftUI command tree. -- **Effort**: M (S if folded into the NSDocument migration). - -### [P0] No auto-save, no Versions, no "Edited" Time Machine integration -- **File**: `TablePro/Views/Main/MainContentCommandActions.swift:420-436` (`saveFileToSourceURL`), `TablePro/Models/Query/QueryTabState.swift:257`. -- **Current**: SQL files are saved only when the user explicitly invokes Save (`Cmd+S`) or accepts the unsaved-changes alert on tab close. `applicationShouldTerminate` (`AppDelegate.swift:99`) shows a custom warning and discards on quit. There is no auto-save timer, no `NSDocument.autosavesInPlace`, no `NSFileVersion` snapshots, no Time Machine "Browse All Versions…" support. -- **HIG says**: "Documents — Modern apps should auto-save documents and integrate with Versions so people can recover earlier states." `NSDocument.autosavesInPlace` returning `true` enables auto-save, the dirty-dot in the close button, the proxy icon's "Locked / Edited / Last opened" tooltip, and the File ▸ Revert To ▸ Browse All Versions… menu. -- **Native examples**: TextEdit, Pages, Notes, Xcode (project files), BBEdit, Tot. Apple's HIG explicitly contrasts "modern" apps (auto-save) against "legacy" apps with manual save dialogs. -- **Fix**: Resolve via the `ReferenceFileDocument` migration (P0 first finding). For SQL files, opt into `NSDocument.autosavesInPlace` (free with `ReferenceFileDocument`) and remove the custom unsaved-changes alert in `closeTab()` / `applicationShouldTerminate`. Keep the custom alert only for non-document state (data-grid edits, structure edits) which don't have a backing file. -- **Effort**: L (folded into NSDocument migration). - -### [P0] File menu lacks Revert / Duplicate / Rename / Move To -- **File**: `TablePro/TableProApp.swift:204-293`. -- **Current**: The custom File menu provides only New Tab, New View, Open Database, Open File, Save Changes, Save As, Close Tab, Export/Import. Missing: Duplicate (`Cmd+Shift+S` in the Apple-standard layout), Rename…, Move To…, Revert To ▸ Last Saved / Browse All Versions…, Save All. These items are auto-inserted by `NSDocument` and required by HIG for document-based apps. -- **HIG says**: "File menu" lists File ▸ Save, Save As, Save All, Duplicate, Rename, Move To, Revert To as the standard set. Removing them silently from a document-based app breaks user muscle memory across the OS. -- **Native examples**: TextEdit, Pages, Numbers, Xcode. Even simple document apps like Tot include Duplicate / Rename / Move To. -- **Fix**: Once SQL files become `NSDocument`-backed, these items appear automatically. If we choose to keep the hand-rolled model, manually add these items to `CommandGroup(after: .newItem)` and wire them to `NSDocumentController` selectors (`saveDocument:`, `duplicateDocument:`, `renameDocument:`, `moveDocument:`, `revertDocumentToSaved:`). -- **Effort**: S if going via NSDocument; M otherwise. - -### [P0] Cmd+G / Cmd+Shift+G (find next/previous) not wired -- **File**: `TablePro/TableProApp.swift:430-435`, `TablePro/Views/Editor/EditorEventRouter.swift:93`. -- **Current**: `Cmd+F` shows the find panel via `EditorEventRouter.shared.showFindPanelForKeyWindow()`. There is no menu item or shortcut for Find Next (`Cmd+G`) or Find Previous (`Cmd+Shift+G`). Search confirms zero usages of `performTextFinderAction`, `findNext`, or `findPrevious` selectors in the project. -- **HIG says**: The Find submenu in the Edit menu must include Find… (`Cmd+F`), Find Next (`Cmd+G`), Find Previous (`Cmd+Shift+G`), Use Selection for Find (`Cmd+E`), and Jump to Selection (`Cmd+J`). All five are auto-installed when the app implements the standard `NSTextFinder` responder chain. CodeEditTextView's `TextView` already implements these. -- **Native examples**: TextEdit, Xcode, Safari, Mail, Pages, BBEdit. Every text editor on macOS. -- **Fix**: Replace the custom `Cmd+F` button with a SwiftUI `CommandGroup(replacing: .textEditing)` that exposes the standard Find submenu, or add explicit Buttons for findNext / findPrevious / useSelectionForFind / jumpToSelection that send `#selector(NSTextFinder.performAction(_:))` (or the responder-chain wrappers). CodeEdit's TextView responds to `performTextFinderAction(_:)` via `NSTextFinderClient`; route through the responder chain. -- **Effort**: S - -### [P0] Edit menu lacks Find Next / Find Previous / Use Selection for Find / Jump to Selection -- **File**: `TablePro/TableProApp.swift:427-457`. -- **Current**: The "Find" Button at line 430 is the only item in the Find area, replacing nothing — there's no submenu structure. SwiftUI's default Find submenu (which would have all five items) is suppressed because the app declares `CommandGroup(after: .pasteboard)` with a single `Button("Find...")`. -- **HIG says**: Same as above; standard Find submenu must include all five items. -- **Native examples**: TextEdit, Xcode, BBEdit. -- **Fix**: Restructure as `CommandGroup(replacing: .textEditing)` + `CommandMenu("Find") { ... }` containing all five canonical entries, or stop suppressing the default Find submenu and only override the items we need. -- **Effort**: S (combine with the previous item). - -### [P0] AppDelegate's custom unsaved-changes alert duplicates `NSDocument` semantics, fights auto-save -- **File**: `TablePro/AppDelegate.swift:99-118`. -- **Current**: `applicationShouldTerminate` walks `MainContentCoordinator.hasAnyUnsavedChanges()` and shows a single warning sheet ("You have unsaved changes / Quitting will discard these changes") with destructive "Quit Anyway". -- **HIG says**: For document apps, Apple owns the quit-with-unsaved-documents flow (`NSDocumentController.reviewUnsavedDocuments(...)`), iterating each unsaved document with the standard "Save / Cancel / Don't Save" dialog. Auto-save apps don't need this alert at all — they auto-save on quit. -- **Native examples**: TextEdit, Pages, Numbers, Xcode all let `NSDocumentController` handle quit review. -- **Fix**: After NSDocument migration, delete this alert and let `NSDocumentController` handle quit review. Keep a custom alert only for non-document state (uncommitted data-grid / structure edits) and present it through `NSApplication.reply(toApplicationShouldTerminate:)`. -- **Effort**: S after document migration; otherwise leave but add per-document iteration. - ---- - -## P1 — Non-idiomatic - -### [P1] Settings window uses old `TabView` toolbar style (pre-macOS 13) -- **File**: `TablePro/Views/Settings/SettingsView.swift:17-66`. -- **Current**: `SettingsView` is a `TabView { ... .tabItem { Label("...", systemImage: "...") } ... }` with a fixed `.frame(width: 720, height: 500)` and 9 tabs (General, Appearance, Editor, Keyboard, AI, Terminal, Integrations, Plugins, Account). This renders as the legacy toolbar-tabbed Preferences window. -- **HIG says**: macOS Sonoma (14) and later use the Settings sidebar/detail layout (`NavigationSplitView` style), matching the system Settings app. SwiftUI's `Settings { }` scene supports both, but `TabView` with `.tabItem` modifiers locks you to the old style. With nine sections, the toolbar gets cramped — the sidebar style is what System Settings, Xcode 15, and Mail now use. -- **Native examples**: Xcode 15 Settings, System Settings, Mail Settings, Notes Settings, Things 3. -- **Fix**: Refactor `SettingsView` into a `NavigationSplitView` with a fixed sidebar listing the nine sections and a detail pane swapping in each section view. Keep the `@AppStorage("selectedSettingsTab")` binding so deep-links from `LaunchIntentRouter` and `AppDelegate.handlePluginsRejected` still navigate to the right pane. -- **Effort**: M - -### [P1] `applicationShouldTerminateAfterLastWindowClosed` not implemented; closing all windows shows Welcome instead of quitting -- **File**: `TablePro/AppDelegate.swift:172-184`. -- **Current**: `windowWillClose(_:)` posts `.mainWindowWillClose` and re-opens the Welcome window when the last main window closes. Apple's standard pattern for menu-bar/utility apps that want to keep running after closing all windows is to override `applicationShouldTerminateAfterLastWindowClosed`. For a document app, the convention is the opposite: closing the last window does **not** quit (the app stays in the dock to handle drag-drop / Open Recent). -- **HIG says**: "Document-based apps should keep running after the last document closes; users open another via File ▸ Open Recent or by dragging onto the Dock icon." TablePro behaves correctly (stays running) but achieves it through a custom Welcome-window springback, which is its own non-native pattern (see `02-windows-interactions.md` for details). -- **Native examples**: TextEdit, Pages — closing the last window leaves the app running; the Dock icon stays bouncy and `applicationShouldHandleReopen` shows the Open dialog. -- **Fix**: After document migration, remove the Welcome-springback. Implement `applicationShouldTerminateAfterLastWindowClosed → false` and rely on `applicationShouldHandleReopen` (already present at `AppDelegate.swift:27`) to surface the Welcome window when the user clicks the Dock icon. -- **Effort**: S - -### [P1] Hand-rolled Save Changes alert duplicates `NSDocument`'s built-in behavior -- **File**: `TablePro/Core/Utilities/UI/AlertHelper.swift:130-163`, `TablePro/Views/Main/MainContentCommandActions.swift:327-350`. -- **Current**: `confirmSaveChanges` builds an `NSAlert` with "Save / Cancel / Don't Save" buttons and a custom `Cmd+D` for "Don't Save". The button order and shortcut are correct (commit at `2f5b4f8e` style — comment at line 139 even references the convention). But the alert is built manually for each call site rather than letting `NSDocument.canClose(withDelegate:...)` handle it. -- **HIG says**: `NSDocument` provides this dialog, with the correct localization in 30+ languages, the correct destructive-action styling, and the correct return mapping. Apps that re-implement this alert get subtly different behavior from system apps (and have to maintain translations). -- **Native examples**: TextEdit, Pages, Xcode. -- **Fix**: Keep `AlertHelper.confirmSaveChanges` only for non-document state (data-grid / structure edits). Route file-dirty checks through `NSDocument.canClose(withDelegate:...)`. -- **Effort**: S after document migration. - -### [P1] No iCloud Drive / ubiquitous documents, despite iCloud entitlement -- **File**: `TablePro/TablePro.entitlements:7-15`. -- **Current**: The entitlements file enables `com.apple.developer.icloud-container-identifiers` and CloudKit, but no `NSUbiquitousContainers` Info.plist key is set and no document presenter / file coordinator code exists. iCloud is wired up only for connection sync. -- **HIG says**: If you ship a document type and use iCloud, also expose iCloud Drive so users can store SQL files in iCloud. Either remove the iCloud entitlement scope or wire it to documents. -- **Native examples**: TextEdit (iCloud Drive container), Pages, Numbers. -- **Fix**: After NSDocument migration, add `NSUbiquitousContainers` to Info.plist with a `NSUbiquitousContainerIsDocumentScopePublic = YES` entry so SQL files appear in `~/Library/Mobile Documents/com~TablePro~iCloud/Documents/`. Or scope the iCloud entitlement strictly to the connection-sync container. -- **Effort**: S - -### [P1] About panel hand-builds links into Credits — should ship `Credits.rtf` -- **File**: `TablePro/TableProApp.swift:146-174`, `TablePro/Resources/`. -- **Current**: The "About TablePro" Button programmatically constructs an `NSAttributedString` with four links (Website / GitHub / Documentation / Sponsor) and passes it via `NSApplication.shared.orderFrontStandardAboutPanel(options: [.credits: ...])`. There is no `Credits.rtf` (or `.html`) in `Resources/` (verified with `find`). -- **HIG says**: The standard about panel reads `Credits.rtf` / `Credits.html` automatically when present. Ship the file in the bundle and let macOS render it. This also localizes (`Credits.rtf` per .lproj) without requiring `String(localized:)` plumbing for link labels. -- **Native examples**: Almost every native macOS app ships `Credits.rtf` (Xcode, Pages, Bartender, Tot). -- **Fix**: Move the four links into a `Credits.rtf` (or per-locale variants in `Resources/en.lproj/Credits.rtf`) and remove the inline construction. Keep just `NSApplication.shared.orderFrontStandardAboutPanel(options: [:])`. -- **Effort**: S - -### [P1] `panel.message` strings hardcoded in English (localization regression) -- **File**: `TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift:140`, `TablePro/Views/Import/ImportDialog.swift:301`. -- **Current**: Two `NSOpenPanel` instances use hardcoded `panel.message = "Select SQL file to import"` and `"Select file to import"`. CLAUDE.md mandates `String(localized:)` for new user-facing strings; the rest of the codebase complies (`SQLFileService.swift:39,52`, `Plugins/InstalledPluginsView.swift:377`, `ERDiagramView.swift:286`). -- **HIG says**: All system-presented file panel messages should localize alongside the rest of the UI. -- **Fix**: Wrap both with `String(localized:)`. -- **Effort**: S - -### [P1] No `UNUserNotificationCenter` usage — no notification for long queries, sync events, or update available -- **File**: codebase-wide (zero matches for `UNUserNotificationCenter`/`UNNotificationRequest`). -- **Current**: TablePro never delivers a user notification. Long queries finish silently (only the in-app status bar updates). Sync conflicts surface only inside the Settings panel. Sparkle uses its own in-app dialog, which is fine. -- **HIG says**: "Notifications — Use notifications for events the user might want to know about when your app isn't in front." A query that takes 30 seconds (default `confirm_destructive_operation` warns at 5 s in MCP) absolutely qualifies. The user might switch to another app while waiting. -- **Native examples**: Xcode (build complete), Mail (new messages), Calendar (alarms), Activity Monitor (high CPU), Time Machine (backup complete). -- **Fix**: Add `UNUserNotificationCenter.current().requestAuthorization(...)` lazily on the first long query (>10 s elapsed). When a query finishes while the app is not the frontmost, post a `UNMutableNotificationContent` with title="Query finished" and body=" rows in ". Same for sync conflicts. Hide behind a setting in General → Notifications. Don't request authorization at launch. -- **Effort**: M - -### [P1] No Quick Look preview for `.sql` files -- **File**: `TablePro/Info.plist:11-105` declares `.sql` documents but there is no Quick Look generator (no `QuickLookThumbnailing` extension, no `QLPreviewPanel` integration). -- **Current**: Selecting a `.sql` file in Finder and pressing space shows the system-default plain-text preview (because `com.tablepro.sql` conforms to `public.plain-text`). It works because of the conformance, but it's the unstyled, monospace plain-text Quick Look — no syntax highlighting, no per-statement count, no theme. -- **HIG says**: Apps that own a document type should ship a Quick Look extension (or thumbnail extension) that renders previews matching the in-app appearance. -- **Native examples**: Xcode (`.swift` previews with syntax highlighting), Pages, Pixelmator Pro. -- **Fix**: Add a `Quick Look Preview` app extension target that renders the SQL with the same `SQLEditorTheme` palette via `CodeEditSourceEditor` in read-only mode. Lower priority than the document model itself. -- **Effort**: M - -### [P1] App is unsandboxed — blocks Mac App Store distribution and trips off-store warnings -- **File**: `TablePro/TablePro.entitlements:19-22`, `TablePro.xcodeproj/project.pbxproj:2272,2350` (`ENABLE_APP_SANDBOX = NO`). -- **Current**: `com.apple.security.app-sandbox = false`. `com.apple.security.cs.disable-library-validation = true` (needed for plugin loading from outside the bundle). Hardened Runtime is on (`ENABLE_HARDENED_RUNTIME = YES`). -- **HIG says**: "Distributing your app — Mac App Store apps must be sandboxed." This is a roadmap concern, not a HIG bug per se, but it's the single biggest gap between TablePro and TablePlus/Postico/Sequel Ace (all sandboxed-when-needed). The root cause for unsandboxed-ness is plugin loading — `.tableplugin` bundles outside the app bundle can't be loaded under sandbox. -- **Native examples**: TablePlus (sandboxed App Store build, unsandboxed direct build), Postico (sandboxed), Sequel Ace (sandboxed). -- **Fix**: Out of scope for this audit, but flagged: a sandbox-eligible build would need plugins to be signed Apple-distributed app extensions (or accept the no-third-party-plugins limitation in the App Store variant). The MCP server / SSH tunnel / network DB drivers all need sandbox-permitted entitlements (`com.apple.security.network.client`, `com.apple.security.files.user-selected.read-write`, etc.). The disable-library-validation entitlement is incompatible with the App Store. Track this as a separate "App Store readiness" project, not part of HIG refactor. -- **Effort**: L (parallel project) - -### [P1] No Services menu integration -- **File**: codebase-wide (zero matches for `registerServicesMenuSendTypes`, `NSServicesMenu`, `writeSelection(to:`). -- **Current**: TablePro neither registers any service ("Run as Query in TablePro") nor accepts services from other apps (e.g., "Format selected SQL"). -- **HIG says**: "Services menu — Provide services for the parts of your app that produce or consume data others might want." Optional, not required. Most database clients ignore services. -- **Native examples**: BBEdit ("New BBEdit Document Containing Selection"), TextEdit, Mail, Safari. -- **Fix**: Optional — provide an "Open in TablePro" service that takes selected SQL text, opens a new query tab on the active connection. Add `NSServices` keys to Info.plist and call `NSApp.servicesProvider = ServicesProvider()` from `applicationDidFinishLaunching`. Low priority compared to document model gaps. -- **Effort**: S (optional) - -### [P1] Custom Cmd+W close-tab logic mixed with `applicationShouldTerminate` quit-review duplicates work -- **File**: `TablePro/Views/Main/MainContentCommandActions.swift:327-350`, `TablePro/AppDelegate.swift:99-118`. -- **Current**: Two separate code paths handle "save before going away" — `closeTab()` shows `confirmSaveChanges`, `applicationShouldTerminate` shows a different alert. They use different button orders, different copy, different shortcut bindings. -- **HIG says**: A document-based app delegates both flows to `NSDocumentController.reviewUnsavedDocuments(...)` for consistency. -- **Native examples**: TextEdit, Pages. -- **Fix**: Consolidate via `NSDocument.canClose(withDelegate:...)` (post-migration). Until then, share a single helper that produces the same alert copy/shortcuts. -- **Effort**: S after document migration. - ---- - -## P2 — Polish - -### [P2] `MainContentCommandActions.openSQLFile()` round-trips through NotificationCenter -- **File**: `TablePro/Views/Main/MainContentCommandActions.swift:558-563`, `TablePro/Views/Main/MainContentCommandActions.swift:786-795`. -- **Current**: `openSQLFile()` shows the panel, then posts `.openSQLFiles` with the URLs. The same actions object subscribes via `observeKeyWindowOnly(.openSQLFiles)` and routes to `TabRouter.shared.route(.openSQLFile(url))`. The Notification round-trip is unnecessary — just call the router directly. -- **HIG says**: N/A; this is internal architecture noise. -- **Fix**: Remove `openSQLFiles` Notification, call `TabRouter` directly from `openSQLFile()`. Notification is also posted from `AppDelegate.application(_:open:)` indirectly through `AppLaunchCoordinator` — pick one path. -- **Effort**: S - -### [P2] `application(_:open:)` doesn't note recent documents — even Drag-and-Drop / Open With opens are silent -- **File**: `TablePro/AppDelegate.swift:17-19`. -- **Current**: `application(_:open:)` forwards to `AppLaunchCoordinator.shared.handleOpenURLs(urls)` and never calls `NSDocumentController.shared.noteNewRecentDocumentURL`. Same gap on every other entry point. Already covered by P0 above; flagged here to ensure the fix touches every entry point. -- **Fix**: Single audit pass to ensure every entry point (open panel, Open With from Finder, drag onto Dock, drag onto window, `tablepro://` URL with file path, recent reopen) feeds through the same `noteNewRecentDocumentURL` hook. -- **Effort**: S - -### [P2] `panel.title` is set on `NSOpenPanel` (deprecated API for sheets) -- **File**: `TablePro/Views/Settings/Plugins/InstalledPluginsView.swift:377`, `TablePro/Views/ERDiagram/ERDiagramView.swift:285`. -- **Current**: `panel.title = ...` is set on the open/save panel. As of macOS 11, `NSSavePanel.title` shows only when the panel is a window, not a sheet. For sheets (which is how these are presented via `beginSheetModal(for:)`), the title is ignored. -- **HIG says**: Use `panel.message` for the descriptive text on sheets; `panel.prompt` to override the default action button label ("Open" / "Save"). -- **Fix**: Replace `panel.title` with `panel.message` (or remove if `panel.message` is already set). Trivial cleanup. -- **Effort**: S - -### [P2] Dock right-click menu omits "New Tab" / "Open Recent" / per-window context -- **File**: `TablePro/AppDelegate.swift:194-236`. -- **Current**: `applicationDockMenu(_:)` returns "Show Welcome Window" + per-connection "Open Connection" submenu. Missing: "Open Recent ▶" (would auto-populate from `NSDocumentController.shared.recentDocumentURLs`), "New Query Tab" (only useful when a connection is active, but standard). -- **HIG says**: Dock menu entries should mirror commonly used menu items. Apple auto-merges "Open Recent" / "New Window" when present in the main menu of a document-based app — meaning a lot of this disappears for free after the document migration. -- **Fix**: After document migration, the Dock menu's recent-documents section is automatic. Add a "New Query Tab in " item for active sessions. -- **Effort**: S - -### [P2] No `applicationShouldHandleReopen` activation logging — silent no-op when Welcome already visible -- **File**: `TablePro/AppDelegate.swift:27-29`, `TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift:224`. -- **Current**: `applicationShouldHandleReopen` returns whatever `AppLaunchCoordinator.handleReopen` returns. The reopen path opens the Welcome window. Standard behavior, but `WelcomeWindowFactory.openOrFront()` doesn't log when no main windows exist — minor observability gap. -- **Fix**: Add OSLog statement to make the path observable. -- **Effort**: S - -### [P2] `WelcomeWindowFactory.openOrFront()` has no equivalent for the standard "show Open dialog when no docs" flow -- **File**: `TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift:224`. -- **Current**: When the user reopens a document app with no windows, Apple's convention is to show the standard Open dialog (or recent-documents picker), not a custom Welcome panel. TablePro substitutes the Welcome window — fine for connection-first UX, but misses the case where the user really did just want to open a `.sql`. -- **HIG says**: `NSApplicationDelegateOpenUntitledFile` lets the app decide what "untitled" means. For database clients, the Welcome window is a reasonable answer. -- **Fix**: After document model exists, decide whether reopen=Welcome or reopen=Open Recent. Likely keep current behavior; flagged for awareness. -- **Effort**: N/A (decision) - ---- - -## Summary table - -| ID | Severity | Title | File anchor | Effort | -|----|----------|-------|-------------|--------| -| 04-01 | P0 | No `NSDocument` / `FileDocument` for SQL files | `TableProApp.swift:628`, `QueryTabState.swift:257` | L | -| 04-02 | P0 | Open Recent menu is missing entirely | `TableProApp.swift:197`, `TabRouter.swift:322` | M | -| 04-03 | P0 | No auto-save / Versions / Time Machine integration | `MainContentCommandActions.swift:420` | L | -| 04-04 | P0 | File menu missing Revert / Duplicate / Rename / Move To | `TableProApp.swift:204` | S (post-04-01) | -| 04-05 | P0 | Cmd+G / Cmd+Shift+G (find next/previous) not wired | `TableProApp.swift:430` | S | -| 04-06 | P0 | Find submenu missing Use Selection / Jump to Selection | `TableProApp.swift:427` | S | -| 04-07 | P0 | `applicationShouldTerminate` reimplements `NSDocument` quit-review | `AppDelegate.swift:99` | S (post-04-01) | -| 04-08 | P1 | Settings uses pre-Sonoma `TabView` toolbar style | `SettingsView.swift:17` | M | -| 04-09 | P1 | Welcome-springback non-native; should use `applicationShouldTerminateAfterLastWindowClosed` | `AppDelegate.swift:172` | S | -| 04-10 | P1 | Custom Save Changes alert duplicates `NSDocument` | `AlertHelper.swift:130` | S (post-04-01) | -| 04-11 | P1 | iCloud entitlement set but no document iCloud Drive support | `TablePro.entitlements:7` | S | -| 04-12 | P1 | About panel programmatic credits — should ship `Credits.rtf` | `TableProApp.swift:146` | S | -| 04-13 | P1 | Hardcoded English `panel.message` strings | `MainContentCoordinator+SidebarActions.swift:140`, `ImportDialog.swift:301` | S | -| 04-14 | P1 | No `UNUserNotificationCenter` for long queries / sync events | (codebase-wide) | M | -| 04-15 | P1 | No Quick Look extension for `.sql` files | `Info.plist:11`, no QL target | M | -| 04-16 | P1 | App is unsandboxed (App Store readiness, separate project) | `TablePro.entitlements:19` | L | -| 04-17 | P1 | No Services menu integration | (codebase-wide) | S (optional) | -| 04-18 | P1 | Cmd+W and quit-review use different alert copy | `MainContentCommandActions.swift:327`, `AppDelegate.swift:99` | S (post-04-01) | -| 04-19 | P2 | `openSQLFile` round-trips through Notification | `MainContentCommandActions.swift:558` | S | -| 04-20 | P2 | `application(_:open:)` doesn't note recent documents | `AppDelegate.swift:17` | S | -| 04-21 | P2 | `panel.title` set on sheet panels (ignored) | `InstalledPluginsView.swift:377`, `ERDiagramView.swift:285` | S | -| 04-22 | P2 | Dock menu omits Open Recent / New Tab | `AppDelegate.swift:194` | S | -| 04-23 | P2 | `applicationShouldHandleReopen` lacks OSLog tracing | `AppDelegate.swift:27` | S | -| 04-24 | P2 | Reopen flow shows Welcome instead of Open dialog (decision flag) | `AppLaunchCoordinator.swift:224` | N/A | - -## Recommendation - -The headline gap is the **document model**. Fixing 04-01 unlocks 04-02 (Open Recent), 04-03 (auto-save / Versions), 04-04 (File-menu items), 04-07 (quit review), 04-10 (Save Changes alert), 04-18 (Cmd+W consistency), and most of 04-22 in one stroke. Land that first, then mop up Find shortcuts (04-05, 04-06), Settings sidebar (04-08), and Credits.rtf (04-12). - -The unsandboxed build (04-16) is the only item that affects strategic distribution (Mac App Store) and is a separate body of work that should be tracked outside this audit. From cbf04fd18d3b4681707df1fffc37199dabf149d3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:30:45 +0700 Subject: [PATCH 46/54] test(mcp): pass tokenId to MCPValidatedToken in bearer auth tests --- .../Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift b/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift index c37da24c8..07ea832c6 100644 --- a/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift +++ b/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift @@ -37,6 +37,7 @@ actor FakeMCPTokenStore: MCPTokenStoreProtocol { struct MCPBearerTokenAuthenticatorTests { private func makePrincipal(label: String = "test", scopes: Set = [.toolsRead]) -> MCPValidatedToken { MCPValidatedToken( + tokenId: UUID(), label: label, scopes: scopes, issuedAt: Date(timeIntervalSince1970: 1_000_000), From e97a4a5b2a045b6decd9283f46734fe281e65086 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:35:56 +0700 Subject: [PATCH 47/54] chore: strip trailing blank line in MCPBridgeLogger --- TablePro/Core/MCP/Transport/MCPBridgeLogger.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift b/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift index 7c09ca7fd..ad9ee8e83 100644 --- a/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift +++ b/TablePro/Core/MCP/Transport/MCPBridgeLogger.swift @@ -67,4 +67,3 @@ public struct MCPCompositeBridgeLogger: MCPBridgeLogger { } } } - From 6d65757cf082dccf5a4ab3d8200014b77b43700f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 20:46:03 +0700 Subject: [PATCH 48/54] fix(mcp): downgrade unknown initialize protocolVersion instead of rejecting The earlier 'strict negotiation' rejected any protocolVersion not in our supported set with -32600 invalid request. Per the MCP transport spec, the server SHOULD respond with the highest version it supports when the client requests a newer one. Claude Code now uses 2025-11-25 (latest); our supportedProtocolVersions is just 2025-03-26, so its initialize was rejected and the bridge probe surfaced as 'Failed to connect'. Make negotiate() always return a valid version, downgrading unknown values to supportedProtocolVersion. Drop the unused 'unsupported version' guard. --- .../MCP/Protocol/Handlers/InitializeHandler.swift | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift index ec69d17b7..909f45f5a 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift @@ -23,12 +23,7 @@ public struct InitializeHandler: MCPMethodHandler { } let requestedVersion = params?["protocolVersion"]?.stringValue - let negotiatedVersion = Self.negotiate(requestedVersion: requestedVersion) - guard let protocolVersion = negotiatedVersion else { - let supported = Self.supportedProtocolVersions.sorted().joined(separator: ", ") - let detail = "Unsupported protocolVersion: \(requestedVersion ?? "missing"). Server supports: \(supported)" - throw MCPProtocolError.invalidRequest(detail: detail) - } + let protocolVersion = Self.negotiate(requestedVersion: requestedVersion) let clientCapabilities = params?["capabilities"] let clientName = params?["clientInfo"]?["name"]?.stringValue ?? "unknown" @@ -64,13 +59,13 @@ public struct InitializeHandler: MCPMethodHandler { return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) } - private static func negotiate(requestedVersion: String?) -> String? { + private static func negotiate(requestedVersion: String?) -> String { guard let requestedVersion, !requestedVersion.isEmpty else { return supportedProtocolVersion } if supportedProtocolVersions.contains(requestedVersion) { return requestedVersion } - return nil + return supportedProtocolVersion } } From 2863f12b45efae1d9936e1cd29ad765fec123496 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 21:13:28 +0700 Subject: [PATCH 49/54] feat(mcp): full support for protocol versions 2025-06-18 and 2025-11-25 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server now advertises and negotiates 2025-11-25 (the latest spec) as the preferred protocol version, with 2025-06-18 and 2025-03-26 as backward-compatible fallbacks. Capabilities advertise only what we genuinely implement — completions endpoint added (was already a stub handler), elicitation deliberately omitted because the server doesn't initiate sampling. 2025-11-25 features wired: - Structured tool output (structuredContent alongside content[]): list_*, describe_table, get_table_ddl, get_connection_status, list_recent_tabs, search_query_history, execute_query, and confirm_destructive_operation now return their structured data twice, once as text for older clients and once as structuredContent for clients that prefer the typed shape. - Tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title) emitted by tools/list. Read tools are marked readOnly+idempotent; execute_query and export_data are openWorld; confirm_destructive_operation is destructive; switch_*, connect, disconnect, and the open_/focus_ UI tools opt out of safety hints. - ServerInfo now includes a 'title' field per the new spec. Tests updated: InitializeHandler accepts all three versions and downgrades unknown ones to 2025-11-25; ToolsListHandler emits the annotation map; ToolsCallHandler passes structuredContent through. --- CHANGELOG.md | 4 + .../Protocol/Handlers/InitializeHandler.swift | 16 ++-- .../Protocol/Handlers/ToolsListHandler.swift | 11 ++- .../ConfirmDestructiveOperationTool.swift | 9 ++- .../Core/MCP/Protocol/Tools/ConnectTool.swift | 9 ++- .../Protocol/Tools/DescribeTableTool.swift | 9 ++- .../MCP/Protocol/Tools/DisconnectTool.swift | 9 ++- .../MCP/Protocol/Tools/ExecuteQueryTool.swift | 9 ++- .../MCP/Protocol/Tools/ExportDataTool.swift | 11 ++- .../Protocol/Tools/FocusQueryTabTool.swift | 9 ++- .../Tools/GetConnectionStatusTool.swift | 9 ++- .../MCP/Protocol/Tools/GetTableDdlTool.swift | 9 ++- .../Protocol/Tools/ListConnectionsTool.swift | 9 ++- .../Protocol/Tools/ListDatabasesTool.swift | 9 ++- .../Protocol/Tools/ListRecentTabsTool.swift | 9 ++- .../MCP/Protocol/Tools/ListSchemasTool.swift | 9 ++- .../MCP/Protocol/Tools/ListTablesTool.swift | 9 ++- .../Tools/MCPToolImplementation.swift | 80 ++++++++++++++++++- .../Tools/OpenConnectionWindowTool.swift | 9 ++- .../MCP/Protocol/Tools/OpenTableTabTool.swift | 9 ++- .../Tools/SearchQueryHistoryTool.swift | 9 ++- .../Protocol/Tools/SwitchDatabaseTool.swift | 9 ++- .../MCP/Protocol/Tools/SwitchSchemaTool.swift | 9 ++- .../Handlers/InitializeHandlerTests.swift | 67 +++++++++++++--- .../Handlers/ToolsCallHandlerTests.swift | 23 ++++++ .../Handlers/ToolsListHandlerTests.swift | 53 ++++++++++++ 26 files changed, 386 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a7380bd..9cb15ea85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- MCP: support for protocol versions `2025-06-18` and `2025-11-25` in addition to `2025-03-26`. Clients on the latest spec no longer downgrade. The server advertises the latest version it supports (`2025-11-25`) and falls back when a client requests an unknown version. +- MCP: structured tool output (`structuredContent`) on every tool. The serialized JSON still appears in `content[].text` for backward compatibility, while 2025-11-25 clients can read the parsed object directly. +- MCP: tool annotations (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) on every tool, plus `serverInfo.title` in `initialize` responses. Read tools advertise `readOnlyHint=true`; `confirm_destructive_operation` advertises `destructiveHint=true`. +- MCP: `completions` capability advertised in `initialize` (the `completion/complete` handler was already wired). - MCP: streaming progress notifications. Long-running tool calls (e.g. `execute_query`) now emit `notifications/progress` events to clients that pass a `_meta.progressToken` in their request. - MCP: pairing redirect carries an explicit `error=denied` parameter when the user clicks Deny so extensions can show a clear error instead of hanging. - MCP: re-pairing the same client name automatically revokes the previous token instead of leaving it active. diff --git a/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift index 909f45f5a..33c45fab6 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift @@ -6,8 +6,12 @@ public struct InitializeHandler: MCPMethodHandler { public static let requiredScopes: Set = [] public static let allowedSessionStates: Set = [.uninitialized] - public static let supportedProtocolVersion = "2025-03-26" - public static let supportedProtocolVersions: Set = ["2025-03-26"] + public static let supportedProtocolVersion = "2025-11-25" + public static let supportedProtocolVersions: Set = [ + "2025-03-26", + "2025-06-18", + "2025-11-25" + ] private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Handler.Initialize") @@ -45,21 +49,23 @@ public struct InitializeHandler: MCPMethodHandler { "subscribe": .bool(false) ]), "prompts": .object(["listChanged": .bool(false)]), - "logging": .object([:]) + "logging": .object([:]), + "completions": .object([:]) ]), "serverInfo": .object([ "name": .string("tablepro"), + "title": .string("TablePro"), "version": .string("1.0.0") ]) ]) Self.logger.info( - "Initialize: client=\(clientName, privacy: .public) version=\(clientVersion ?? "-", privacy: .public) protocol=\(protocolVersion, privacy: .public)" + "Initialize: client=\(clientName, privacy: .public) version=\(clientVersion ?? "-", privacy: .public) protocol=\(protocolVersion, privacy: .public) requested=\(requestedVersion ?? "-", privacy: .public)" ) return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) } - private static func negotiate(requestedVersion: String?) -> String { + public static func negotiate(requestedVersion: String?) -> String { guard let requestedVersion, !requestedVersion.isEmpty else { return supportedProtocolVersion } diff --git a/TablePro/Core/MCP/Protocol/Handlers/ToolsListHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/ToolsListHandler.swift index 5e58eaa29..6c45d2d8c 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/ToolsListHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/ToolsListHandler.swift @@ -10,11 +10,18 @@ public struct ToolsListHandler: MCPMethodHandler { public func handle(params: JsonValue?, context: MCPRequestContext) async throws -> JsonRpcMessage { let tools: [JsonValue] = MCPToolRegistry.allTools.map { tool in let toolType = type(of: tool) - return .object([ + var fields: [String: JsonValue] = [ "name": .string(toolType.name), "description": .string(toolType.description), "inputSchema": toolType.inputSchema - ]) + ] + if let title = toolType.title { + fields["title"] = .string(title) + } + if let annotationsValue = toolType.annotations.asJsonValue { + fields["annotations"] = annotationsValue + } + return .object(fields) } let result: JsonValue = .object(["tools": .array(tools)]) return MCPMethodHandlerHelpers.successResponse(id: context.requestId, result: result) diff --git a/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift b/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift index baa07715a..930826b52 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift @@ -29,6 +29,13 @@ public struct ConfirmDestructiveOperationTool: MCPToolImplementation { ]) ]) public static let requiredScopes: Set = [.toolsWrite] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Confirm Destructive Operation"), + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true + ) private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") private static let requiredPhrase = "I understand this is irreversible" @@ -87,6 +94,6 @@ public struct ConfirmDestructiveOperationTool: MCPToolImplementation { principalLabel: context.principal.metadata.label ) - return .json(result) + return .structured(result) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift b/TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift index f2c4d4e5e..5ba8ca898 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ConnectTool.swift @@ -15,6 +15,13 @@ public struct ConnectTool: MCPToolImplementation { "required": .array([.string("connection_id")]) ]) public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Connect"), + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + ) private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") @@ -28,6 +35,6 @@ public struct ConnectTool: MCPToolImplementation { let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") Self.logger.debug("connect tool invoked for connection \(connectionId.uuidString, privacy: .public)") let payload = try await services.connectionBridge.connect(connectionId: connectionId) - return .json(payload) + return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift b/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift index 8cb3369d7..c034c6a57 100644 --- a/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift @@ -6,6 +6,13 @@ public struct DescribeTableTool: MCPToolImplementation { localized: "Get detailed table structure: columns, indexes, foreign keys, and DDL" ) public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Describe Table"), + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) public static let inputSchema: JsonValue = .object([ "type": .string("object"), @@ -42,6 +49,6 @@ public struct DescribeTableTool: MCPToolImplementation { table: table, schema: schema ) - return .json(payload) + return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/DisconnectTool.swift b/TablePro/Core/MCP/Protocol/Tools/DisconnectTool.swift index f82e2d266..a41f3726d 100644 --- a/TablePro/Core/MCP/Protocol/Tools/DisconnectTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/DisconnectTool.swift @@ -15,6 +15,13 @@ public struct DisconnectTool: MCPToolImplementation { "required": .array([.string("connection_id")]) ]) public static let requiredScopes: Set = [.toolsWrite] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Disconnect"), + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + ) private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") @@ -29,6 +36,6 @@ public struct DisconnectTool: MCPToolImplementation { Self.logger.debug("disconnect tool invoked for connection \(connectionId.uuidString, privacy: .public)") try await services.connectionBridge.disconnect(connectionId: connectionId) let result: JsonValue = .object(["status": .string("disconnected")]) - return .json(result) + return .structured(result) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift index 766691353..1b85e8391 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift @@ -37,6 +37,13 @@ public struct ExecuteQueryTool: MCPToolImplementation { "required": .array([.string("connection_id"), .string("query")]) ]) public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Execute Query"), + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true + ) private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") @@ -130,7 +137,7 @@ public struct ExecuteQueryTool: MCPToolImplementation { await context.progress.emit(progress: 0.8, total: 1.0, message: "Formatting result") await context.progress.emit(progress: 1.0, total: 1.0, message: "Done") - return .json(result) + return .structured(result) } private func classifyAndAuthorize( diff --git a/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift index df31d8664..5bcbffea4 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift @@ -39,6 +39,13 @@ public struct ExportDataTool: MCPToolImplementation { "required": .array([.string("connection_id"), .string("format")]) ]) public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Export Data"), + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true + ) private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") private static let allowedFormats: Set = ["csv", "json", "sql"] @@ -168,7 +175,7 @@ public struct ExportDataTool: MCPToolImplementation { "path": .string(fileURL.path), "rows_exported": .int(totalRowsExported) ]) - return .json(response) + return .structured(response) } let response: JsonValue @@ -177,7 +184,7 @@ public struct ExportDataTool: MCPToolImplementation { } else { response = .object(["exports": .array(exportResults)]) } - return .json(response) + return .structured(response) } static func validateExportTableName(_ table: String) throws { diff --git a/TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift b/TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift index 1fedef48d..909891d01 100644 --- a/TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/FocusQueryTabTool.swift @@ -5,6 +5,13 @@ public struct FocusQueryTabTool: MCPToolImplementation { public static let name = "focus_query_tab" public static let description = String(localized: "Focus an already-open tab by id (returned from list_recent_tabs).") public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Focus Query Tab"), + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) public static let inputSchema: JsonValue = .object([ "type": .string("object"), @@ -54,6 +61,6 @@ public struct FocusQueryTabTool: MCPToolImplementation { dict["window_id"] = .string(windowId.uuidString) } - return .json(.object(dict)) + return .structured(.object(dict)) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift b/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift index 7f81b26d8..e09b43ab1 100644 --- a/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/GetConnectionStatusTool.swift @@ -4,6 +4,13 @@ public struct GetConnectionStatusTool: MCPToolImplementation { public static let name = "get_connection_status" public static let description = String(localized: "Get detailed status of a database connection") public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Get Connection Status"), + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) public static let inputSchema: JsonValue = .object([ "type": .string("object"), @@ -25,6 +32,6 @@ public struct GetConnectionStatusTool: MCPToolImplementation { ) async throws -> MCPToolCallResult { let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") let payload = try await services.connectionBridge.getConnectionStatus(connectionId: connectionId) - return .json(payload) + return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift b/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift index 40815a703..6f396b7dd 100644 --- a/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift @@ -4,6 +4,13 @@ public struct GetTableDdlTool: MCPToolImplementation { public static let name = "get_table_ddl" public static let description = String(localized: "Get the CREATE TABLE DDL statement for a table") public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Get Table DDL"), + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) public static let inputSchema: JsonValue = .object([ "type": .string("object"), @@ -40,6 +47,6 @@ public struct GetTableDdlTool: MCPToolImplementation { table: table, schema: schema ) - return .json(payload) + return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift index 21836675e..7a687e5bf 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListConnectionsTool.swift @@ -4,6 +4,13 @@ public struct ListConnectionsTool: MCPToolImplementation { public static let name = "list_connections" public static let description = String(localized: "List all saved database connections with their status") public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "List Connections"), + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) public static let inputSchema: JsonValue = .object([ "type": .string("object"), @@ -19,6 +26,6 @@ public struct ListConnectionsTool: MCPToolImplementation { services: MCPToolServices ) async throws -> MCPToolCallResult { let payload = await services.connectionBridge.listConnections() - return .json(payload) + return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift index af56b963e..e6ad4a4f3 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListDatabasesTool.swift @@ -4,6 +4,13 @@ public struct ListDatabasesTool: MCPToolImplementation { public static let name = "list_databases" public static let description = String(localized: "List all databases on the server") public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "List Databases"), + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) public static let inputSchema: JsonValue = .object([ "type": .string("object"), @@ -25,6 +32,6 @@ public struct ListDatabasesTool: MCPToolImplementation { ) async throws -> MCPToolCallResult { let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") let payload = try await services.connectionBridge.listDatabases(connectionId: connectionId) - return .json(payload) + return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift index 32e70f239..74dc2badc 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListRecentTabsTool.swift @@ -6,6 +6,13 @@ public struct ListRecentTabsTool: MCPToolImplementation { localized: "List currently open tabs across all TablePro windows. Returns connection, tab type, table name, and titles for each tab." ) public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "List Recent Tabs"), + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) public static let inputSchema: JsonValue = .object([ "type": .string("object"), @@ -56,6 +63,6 @@ public struct ListRecentTabsTool: MCPToolImplementation { return .object(dict) } - return .json(.object(["tabs": .array(payload)])) + return .structured(.object(["tabs": .array(payload)])) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift index ada6ca116..bb0e98606 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift @@ -4,6 +4,13 @@ public struct ListSchemasTool: MCPToolImplementation { public static let name = "list_schemas" public static let description = String(localized: "List schemas in a database") public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "List Schemas"), + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) public static let inputSchema: JsonValue = .object([ "type": .string("object"), @@ -35,6 +42,6 @@ public struct ListSchemasTool: MCPToolImplementation { } let payload = try await services.connectionBridge.listSchemas(connectionId: connectionId) - return .json(payload) + return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift index 8ab085a45..7dd404e34 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift @@ -4,6 +4,13 @@ public struct ListTablesTool: MCPToolImplementation { public static let name = "list_tables" public static let description = String(localized: "List tables and views in a database") public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "List Tables"), + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) public static let inputSchema: JsonValue = .object([ "type": .string("object"), @@ -51,6 +58,6 @@ public struct ListTablesTool: MCPToolImplementation { connectionId: connectionId, includeRowCounts: includeRowCounts ) - return .json(payload) + return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift b/TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift index 9c652cedd..0876b2b1a 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPToolImplementation.swift @@ -2,25 +2,79 @@ import Foundation public protocol MCPToolImplementation: Sendable { static var name: String { get } + static var title: String? { get } static var description: String { get } static var inputSchema: JsonValue { get } + static var annotations: MCPToolAnnotations { get } static var requiredScopes: Set { get } func call(arguments: JsonValue, context: MCPRequestContext, services: MCPToolServices) async throws -> MCPToolCallResult } public extension MCPToolImplementation { + static var title: String? { nil } + static var annotations: MCPToolAnnotations { MCPToolAnnotations() } + var name: String { Self.name } var description: String { Self.description } var inputSchema: JsonValue { Self.inputSchema } var requiredScopes: Set { Self.requiredScopes } } +public struct MCPToolAnnotations: Sendable, Equatable { + public let title: String? + public let readOnlyHint: Bool? + public let destructiveHint: Bool? + public let idempotentHint: Bool? + public let openWorldHint: Bool? + + public init( + title: String? = nil, + readOnlyHint: Bool? = nil, + destructiveHint: Bool? = nil, + idempotentHint: Bool? = nil, + openWorldHint: Bool? = nil + ) { + self.title = title + self.readOnlyHint = readOnlyHint + self.destructiveHint = destructiveHint + self.idempotentHint = idempotentHint + self.openWorldHint = openWorldHint + } + + public var asJsonValue: JsonValue? { + var fields: [String: JsonValue] = [:] + if let title { + fields["title"] = .string(title) + } + if let readOnlyHint { + fields["readOnlyHint"] = .bool(readOnlyHint) + } + if let destructiveHint { + fields["destructiveHint"] = .bool(destructiveHint) + } + if let idempotentHint { + fields["idempotentHint"] = .bool(idempotentHint) + } + if let openWorldHint { + fields["openWorldHint"] = .bool(openWorldHint) + } + guard !fields.isEmpty else { return nil } + return .object(fields) + } +} + public struct MCPToolCallResult: Sendable { public let content: [MCPToolContentItem] + public let structuredContent: JsonValue? public let isError: Bool - public init(content: [MCPToolContentItem], isError: Bool = false) { + public init( + content: [MCPToolContentItem], + structuredContent: JsonValue? = nil, + isError: Bool = false + ) { self.content = content + self.structuredContent = structuredContent self.isError = isError } @@ -29,10 +83,27 @@ public struct MCPToolCallResult: Sendable { } public static func json(_ value: JsonValue, isError: Bool = false) -> MCPToolCallResult { + let encoded = encodeJsonString(value) + return MCPToolCallResult(content: [.text(encoded)], isError: isError) + } + + public static func structured(_ value: JsonValue, isError: Bool = false) -> MCPToolCallResult { + let encoded = encodeJsonString(value) + return MCPToolCallResult( + content: [.text(encoded)], + structuredContent: value, + isError: isError + ) + } + + private static func encodeJsonString(_ value: JsonValue) -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] - let encoded = (try? encoder.encode(value)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}" - return MCPToolCallResult(content: [.text(encoded)], isError: isError) + guard let data = try? encoder.encode(value), + let string = String(data: data, encoding: .utf8) else { + return "{}" + } + return string } } @@ -55,6 +126,9 @@ public extension MCPToolCallResult { var fields: [String: JsonValue] = [ "content": .array(content.map { $0.asJsonValue }) ] + if let structuredContent { + fields["structuredContent"] = structuredContent + } if isError { fields["isError"] = .bool(true) } diff --git a/TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift b/TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift index 5a2ab3c4c..6d65e7f5a 100644 --- a/TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift @@ -18,6 +18,13 @@ public struct OpenConnectionWindowTool: MCPToolImplementation { "required": .array([.string("connection_id")]) ]) public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Open Connection Window"), + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") @@ -49,7 +56,7 @@ public struct OpenConnectionWindowTool: MCPToolImplementation { "connection_id": .string(connectionId.uuidString), "window_id": .string(windowId.uuidString) ]) - return .json(result) + return .structured(result) } private func ensureConnectionExists(_ connectionId: UUID) async throws { diff --git a/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift b/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift index 91b7d6362..9ab75b6ef 100644 --- a/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift @@ -30,6 +30,13 @@ public struct OpenTableTabTool: MCPToolImplementation { "required": .array([.string("connection_id"), .string("table_name")]) ]) public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Open Table Tab"), + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") @@ -69,7 +76,7 @@ public struct OpenTableTabTool: MCPToolImplementation { "table_name": .string(tableName), "window_id": .string(windowId.uuidString) ]) - return .json(result) + return .structured(result) } private func ensureConnectionExists(_ connectionId: UUID) async throws { diff --git a/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift b/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift index 40cc81b49..c13dad637 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SearchQueryHistoryTool.swift @@ -6,6 +6,13 @@ public struct SearchQueryHistoryTool: MCPToolImplementation { localized: "Search saved query history. Returns matching entries with execution time, row count, and outcome." ) public static let requiredScopes: Set = [.toolsRead] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Search Query History"), + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) public static let inputSchema: JsonValue = .object([ "type": .string("object"), @@ -97,6 +104,6 @@ public struct SearchQueryHistoryTool: MCPToolImplementation { return .object(dict) } - return .json(.object(["entries": .array(payload)])) + return .structured(.object(["entries": .array(payload)])) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift b/TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift index 45c8d99fc..26c8c273b 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SwitchDatabaseTool.swift @@ -19,6 +19,13 @@ public struct SwitchDatabaseTool: MCPToolImplementation { "required": .array([.string("connection_id"), .string("database")]) ]) public static let requiredScopes: Set = [.toolsWrite] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Switch Database"), + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") @@ -36,6 +43,6 @@ public struct SwitchDatabaseTool: MCPToolImplementation { connectionId: connectionId, database: database ) - return .json(payload) + return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift b/TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift index bd07d16f0..e26bdea75 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SwitchSchemaTool.swift @@ -19,6 +19,13 @@ public struct SwitchSchemaTool: MCPToolImplementation { "required": .array([.string("connection_id"), .string("schema")]) ]) public static let requiredScopes: Set = [.toolsWrite] + public static let annotations = MCPToolAnnotations( + title: String(localized: "Switch Schema"), + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Tools") @@ -36,6 +43,6 @@ public struct SwitchSchemaTool: MCPToolImplementation { connectionId: connectionId, schema: schema ) - return .json(payload) + return .structured(payload) } } diff --git a/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift index da6f80223..eb946ae0f 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/InitializeHandlerTests.swift @@ -19,7 +19,7 @@ final class InitializeHandlerTests: XCTestCase { let context = try await makeContext() let handler = InitializeHandler() let params: JsonValue = .object([ - "protocolVersion": .string("2025-03-26"), + "protocolVersion": .string("2025-11-25"), "clientInfo": .object([ "name": .string("test-client"), "version": .string("1.2.3") @@ -39,7 +39,7 @@ final class InitializeHandlerTests: XCTestCase { return } - XCTAssertEqual(result["protocolVersion"]?.stringValue, InitializeHandler.supportedProtocolVersion) + XCTAssertEqual(result["protocolVersion"]?.stringValue, "2025-11-25") guard let serverInfo = result["serverInfo"], case .object(let serverInfoDict) = serverInfo else { XCTFail("Expected serverInfo object") @@ -56,13 +56,36 @@ final class InitializeHandlerTests: XCTestCase { XCTAssertNotNil(capDict["resources"]) XCTAssertNotNil(capDict["prompts"]) XCTAssertNotNil(capDict["logging"]) + XCTAssertNotNil(capDict["completions"]) + } + + func testEchoesBackEachSupportedProtocolVersion() async throws { + for version in ["2025-03-26", "2025-06-18", "2025-11-25"] { + let context = try await makeContext() + let handler = InitializeHandler() + let params: JsonValue = .object([ + "protocolVersion": .string(version), + "clientInfo": .object(["name": .string("client")]) + ]) + + let response = try await handler.handle(params: params, context: context) + guard case .successResponse(let success) = response, + case .object(let result) = success.result else { + XCTFail("Expected success object for version \(version)") + return + } + XCTAssertEqual(result["protocolVersion"]?.stringValue, version) + + let negotiated = await context.session.negotiatedProtocolVersion + XCTAssertEqual(negotiated, version) + } } func testRecordsClientInfoOnSession() async throws { let context = try await makeContext() let handler = InitializeHandler() let params: JsonValue = .object([ - "protocolVersion": .string("2025-03-26"), + "protocolVersion": .string("2025-06-18"), "clientInfo": .object([ "name": .string("acme-cli"), "version": .string("9.9.9") @@ -77,7 +100,7 @@ final class InitializeHandlerTests: XCTestCase { XCTAssertEqual(info?.version, "9.9.9") let negotiated = await context.session.negotiatedProtocolVersion - XCTAssertEqual(negotiated, "2025-03-26") + XCTAssertEqual(negotiated, "2025-06-18") let recordedCapabilities = await context.session.clientCapabilities XCTAssertEqual(recordedCapabilities, .object(["x": .bool(true)])) @@ -98,7 +121,7 @@ final class InitializeHandlerTests: XCTestCase { let context = try await makeContext() let handler = InitializeHandler() let params: JsonValue = .object([ - "protocolVersion": .string("2025-03-26"), + "protocolVersion": .string("2025-11-25"), "clientInfo": .object(["name": .string("first")]) ]) @@ -112,7 +135,7 @@ final class InitializeHandlerTests: XCTestCase { } } - func testRejectsUnsupportedProtocolVersion() async throws { + func testUnknownProtocolVersionDowngradesToLatest() async throws { let context = try await makeContext() let handler = InitializeHandler() let params: JsonValue = .object([ @@ -120,12 +143,34 @@ final class InitializeHandlerTests: XCTestCase { "clientInfo": .object(["name": .string("vintage")]) ]) - do { - _ = try await handler.handle(params: params, context: context) - XCTFail("Expected handler to throw on unsupported protocolVersion") - } catch let error as MCPProtocolError { - XCTAssertEqual(error.code, JsonRpcErrorCode.invalidRequest) + let response = try await handler.handle(params: params, context: context) + guard case .successResponse(let success) = response, + case .object(let result) = success.result else { + XCTFail("Expected success object") + return + } + XCTAssertEqual(result["protocolVersion"]?.stringValue, InitializeHandler.supportedProtocolVersion) + XCTAssertEqual(InitializeHandler.supportedProtocolVersion, "2025-11-25") + + let negotiated = await context.session.negotiatedProtocolVersion + XCTAssertEqual(negotiated, "2025-11-25") + } + + func testNewerUnknownProtocolVersionDowngradesToLatest() async throws { + let context = try await makeContext() + let handler = InitializeHandler() + let params: JsonValue = .object([ + "protocolVersion": .string("2099-01-01"), + "clientInfo": .object(["name": .string("future")]) + ]) + + let response = try await handler.handle(params: params, context: context) + guard case .successResponse(let success) = response, + case .object(let result) = success.result else { + XCTFail("Expected success object") + return } + XCTAssertEqual(result["protocolVersion"]?.stringValue, "2025-11-25") } func testMissingProtocolVersionFallsBackToSupported() async throws { diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift index 10d1ecf4c..602dc87df 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/ToolsCallHandlerTests.swift @@ -76,6 +76,29 @@ struct ToolsCallHandlerTests { #expect(content?.first?["type"]?.stringValue == "text") } + @Test("list_connections includes structuredContent for 2025-11-25 clients") + func listConnectionsExposesStructuredContent() async throws { + let handler = makeHandler() + let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/call") + let params: JsonValue = .object([ + "name": .string("list_connections"), + "arguments": .object([:]) + ]) + + let response = try await handler.handle(params: params, context: context) + guard case .successResponse(let success) = response else { + Issue.record("expected success, got \(response)") + return + } + let structured = success.result["structuredContent"] + #expect(structured != nil) + if case .object = structured { + // ok + } else { + Issue.record("expected structuredContent to be an object") + } + } + @Test("get_table_ddl with missing connection_id returns invalid params") func getTableDdlMissingId() async throws { let handler = makeHandler() diff --git a/TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift b/TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift index 9d6b0bccd..6e3ea4418 100644 --- a/TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift +++ b/TableProTests/Core/MCP/Protocol/Handlers/ToolsListHandlerTests.swift @@ -66,6 +66,59 @@ struct ToolsListHandlerTests { } } + @Test("Each tool exposes annotations with hints") + func toolsExposeAnnotations() async throws { + let response = try await runToolsList() + let tools = response["tools"]?.arrayValue ?? [] + + for tool in tools { + guard let name = tool["name"]?.stringValue else { + Issue.record("missing tool name") + continue + } + guard case .object(let annotations) = tool["annotations"] else { + Issue.record("missing annotations for tool \(name)") + continue + } + #expect(annotations["title"]?.stringValue?.isEmpty == false) + #expect(annotations["readOnlyHint"]?.boolValue != nil) + #expect(annotations["destructiveHint"]?.boolValue != nil) + #expect(annotations["idempotentHint"]?.boolValue != nil) + #expect(annotations["openWorldHint"]?.boolValue != nil) + } + } + + @Test("Read tools advertise readOnlyHint=true") + func readToolsAreReadOnly() async throws { + let response = try await runToolsList() + let tools = response["tools"]?.arrayValue ?? [] + + let readOnlyExpected: Set = [ + "list_connections", + "get_connection_status", + "list_databases", + "list_schemas", + "list_tables", + "describe_table", + "get_table_ddl", + "list_recent_tabs", + "search_query_history" + ] + for tool in tools { + guard let name = tool["name"]?.stringValue, readOnlyExpected.contains(name) else { continue } + #expect(tool["annotations"]?["readOnlyHint"]?.boolValue == true) + } + } + + @Test("confirm_destructive_operation advertises destructiveHint=true") + func destructiveToolFlagged() async throws { + let response = try await runToolsList() + let tools = response["tools"]?.arrayValue ?? [] + let target = tools.first { $0["name"]?.stringValue == "confirm_destructive_operation" } + #expect(target != nil) + #expect(target?["annotations"]?["destructiveHint"]?.boolValue == true) + } + private func runToolsList() async throws -> JsonValue { let handler = ToolsListHandler() let context = await MCPProtocolHandlerTestSupport.makeContext(method: "tools/list") From d2ce0be4c571c177466b395a21f162d910e0df6c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 21:20:03 +0700 Subject: [PATCH 50/54] docs(versioning): document 2025-06-18 + 2025-11-25 support, capabilities, annotations --- docs/external-api/versioning.mdx | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/external-api/versioning.mdx b/docs/external-api/versioning.mdx index 6902c0ebb..380cd20a7 100644 --- a/docs/external-api/versioning.mdx +++ b/docs/external-api/versioning.mdx @@ -7,7 +7,25 @@ description: Stability policy for the URL scheme, MCP tools, and resource catalo The External API follows TablePro's semver. The contract is the URL scheme, the MCP tool catalog, the resource list, and the pairing flow. -The MCP server reports `protocolVersion: "2025-03-26"` from `initialize`. That value comes from the [Model Context Protocol spec](https://modelcontextprotocol.io) and is independent of TablePro's app version. +The MCP server speaks three versions of the [Model Context Protocol spec](https://modelcontextprotocol.io): `2025-03-26`, `2025-06-18`, and `2025-11-25`. On `initialize`, the server echoes whichever version the client requested if it matches one of these; otherwise it returns `2025-11-25` (the latest supported) and the client decides whether to proceed. The protocol version is independent of TablePro's app version. + +### Capabilities advertised + +The server advertises only what it actually implements: + +- `tools.listChanged: false` — the tool catalog is static across a session +- `resources.listChanged: false`, `resources.subscribe: false` — resources are static; clients should poll +- `prompts.listChanged: false` — no prompts yet +- `logging` — accepts `logging/setLevel` +- `completions` — accepts `completion/complete` (returns empty list today) + +The server does not advertise the `elicitation` capability — it never initiates client-side prompts. + +### 2025-11-25 features + +- **Structured tool output**: `tools/call` results include both a `content[]` text representation (for older clients) and a typed `structuredContent` field (for clients that prefer the structured shape). Tools that emit structured data: `list_*`, `describe_table`, `get_table_ddl`, `get_connection_status`, `list_recent_tabs`, `search_query_history`, `execute_query`, `confirm_destructive_operation`. +- **Tool annotations**: `tools/list` includes `annotations` per tool. `readOnlyHint` and `idempotentHint` for read tools, `destructiveHint` for `confirm_destructive_operation`, `openWorldHint` for `execute_query` and `export_data`. Clients can use these to gate tool invocations behind user approval. +- **Server `title`**: `serverInfo` includes a `title` field (`"TablePro"`) alongside `name` and `version`. ## Stability rules From ac57d4eed027e9241631d0fcfc8f83223780eda6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 2 May 2026 21:22:02 +0700 Subject: [PATCH 51/54] docs(versioning): rewrite the new section in plain prose, drop em dashes --- docs/external-api/versioning.mdx | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/external-api/versioning.mdx b/docs/external-api/versioning.mdx index 380cd20a7..268631c3c 100644 --- a/docs/external-api/versioning.mdx +++ b/docs/external-api/versioning.mdx @@ -7,25 +7,25 @@ description: Stability policy for the URL scheme, MCP tools, and resource catalo The External API follows TablePro's semver. The contract is the URL scheme, the MCP tool catalog, the resource list, and the pairing flow. -The MCP server speaks three versions of the [Model Context Protocol spec](https://modelcontextprotocol.io): `2025-03-26`, `2025-06-18`, and `2025-11-25`. On `initialize`, the server echoes whichever version the client requested if it matches one of these; otherwise it returns `2025-11-25` (the latest supported) and the client decides whether to proceed. The protocol version is independent of TablePro's app version. +The MCP server accepts three versions from the [MCP spec](https://modelcontextprotocol.io): `2025-03-26`, `2025-06-18`, and `2025-11-25`. On `initialize` the server echoes the version the client asked for. If the client asks for something else, the server returns `2025-11-25` and the client decides whether to use it. -### Capabilities advertised +### Capabilities -The server advertises only what it actually implements: +The server reports these capabilities. Anything not listed here is not supported. -- `tools.listChanged: false` — the tool catalog is static across a session -- `resources.listChanged: false`, `resources.subscribe: false` — resources are static; clients should poll -- `prompts.listChanged: false` — no prompts yet -- `logging` — accepts `logging/setLevel` -- `completions` — accepts `completion/complete` (returns empty list today) +- `tools.listChanged: false`. The tool list does not change during a session. +- `resources.listChanged: false`, `resources.subscribe: false`. Resources are static. Clients that need fresh data should call `resources/read` again. +- `prompts.listChanged: false`. No prompts yet. +- `logging`. Accepts `logging/setLevel`. +- `completions`. Accepts `completion/complete`. Returns an empty list today. -The server does not advertise the `elicitation` capability — it never initiates client-side prompts. +There is no `elicitation` capability. The server does not ask clients for input. -### 2025-11-25 features +### What changed in 2025-11-25 -- **Structured tool output**: `tools/call` results include both a `content[]` text representation (for older clients) and a typed `structuredContent` field (for clients that prefer the structured shape). Tools that emit structured data: `list_*`, `describe_table`, `get_table_ddl`, `get_connection_status`, `list_recent_tabs`, `search_query_history`, `execute_query`, `confirm_destructive_operation`. -- **Tool annotations**: `tools/list` includes `annotations` per tool. `readOnlyHint` and `idempotentHint` for read tools, `destructiveHint` for `confirm_destructive_operation`, `openWorldHint` for `execute_query` and `export_data`. Clients can use these to gate tool invocations behind user approval. -- **Server `title`**: `serverInfo` includes a `title` field (`"TablePro"`) alongside `name` and `version`. +- `tools/call` results now include `structuredContent` next to `content[]`. Older clients keep reading the text content. Newer clients can read the typed object directly. Tools that return data use both: `list_*`, `describe_table`, `get_table_ddl`, `get_connection_status`, `list_recent_tabs`, `search_query_history`, `execute_query`, `confirm_destructive_operation`. +- `tools/list` returns annotations per tool. `readOnlyHint` and `idempotentHint` mark read tools. `destructiveHint` marks `confirm_destructive_operation`. `openWorldHint` marks `execute_query` and `export_data`. +- `serverInfo` includes `title: "TablePro"`. ## Stability rules From 5e3e5752762877c9bd79592786f38b6804524af8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 3 May 2026 19:55:34 +0700 Subject: [PATCH 52/54] refactor(mcp): native AsyncStream, drop HttpWriter, typed SSE terminate, widen token fingerprint --- .../Auth/MCPBearerTokenAuthenticator.swift | 2 +- TablePro/Core/MCP/MCPAuditLogStorage.swift | 5 -- TablePro/Core/MCP/MCPPairingService.swift | 5 -- TablePro/Core/MCP/MCPPortAllocator.swift | 5 -- TablePro/Core/MCP/MCPServerManager.swift | 2 +- TablePro/Core/MCP/MCPTLSManager.swift | 5 -- .../Protocol/Handlers/InitializeHandler.swift | 6 +- .../Core/MCP/Session/MCPSessionState.swift | 3 + .../Transport/MCPHttpServerTransport.swift | 70 +++++++++++------- .../Transport/MCPStdioMessageTransport.swift | 57 ++++----------- .../MCPStreamableHttpClientTransport.swift | 56 +++----------- TablePro/Resources/Localizable.xcstrings | 73 ++++++++++++++++++- 12 files changed, 151 insertions(+), 138 deletions(-) diff --git a/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift index f8d159eb9..5177af407 100644 --- a/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift +++ b/TablePro/Core/MCP/Auth/MCPBearerTokenAuthenticator.swift @@ -208,6 +208,6 @@ public actor MCPBearerTokenAuthenticator: MCPAuthenticator { guard let data = token.data(using: .utf8) else { return "" } let digest = SHA256.hash(data: data) let hex = digest.map { String(format: "%02x", $0) }.joined() - return String(hex.prefix(8)) + return String(hex.prefix(16)) } } diff --git a/TablePro/Core/MCP/MCPAuditLogStorage.swift b/TablePro/Core/MCP/MCPAuditLogStorage.swift index 02fe1cef5..3aaf13e6d 100644 --- a/TablePro/Core/MCP/MCPAuditLogStorage.swift +++ b/TablePro/Core/MCP/MCPAuditLogStorage.swift @@ -1,8 +1,3 @@ -// -// MCPAuditLogStorage.swift -// TablePro -// - import Foundation import os import SQLite3 diff --git a/TablePro/Core/MCP/MCPPairingService.swift b/TablePro/Core/MCP/MCPPairingService.swift index a5f9600da..1b6729767 100644 --- a/TablePro/Core/MCP/MCPPairingService.swift +++ b/TablePro/Core/MCP/MCPPairingService.swift @@ -1,8 +1,3 @@ -// -// MCPPairingService.swift -// TablePro -// - import AppKit import CryptoKit import Foundation diff --git a/TablePro/Core/MCP/MCPPortAllocator.swift b/TablePro/Core/MCP/MCPPortAllocator.swift index 9a354665a..402349cec 100644 --- a/TablePro/Core/MCP/MCPPortAllocator.swift +++ b/TablePro/Core/MCP/MCPPortAllocator.swift @@ -1,8 +1,3 @@ -// -// MCPPortAllocator.swift -// TablePro -// - import Darwin import Foundation import os diff --git a/TablePro/Core/MCP/MCPServerManager.swift b/TablePro/Core/MCP/MCPServerManager.swift index 6ab039793..3a6b90516 100644 --- a/TablePro/Core/MCP/MCPServerManager.swift +++ b/TablePro/Core/MCP/MCPServerManager.swift @@ -289,7 +289,7 @@ final class MCPServerManager { let extraSessions = await sessionStore.sessionIds(forPrincipalTokenId: tokenId) let toTerminate = Set(cancelledSessions + extraSessions) for sessionId in toTerminate { - await sessionStore.terminate(id: sessionId, reason: .clientRequested) + await sessionStore.terminate(id: sessionId, reason: .tokenRevoked) } if !toTerminate.isEmpty { Self.logger.info( diff --git a/TablePro/Core/MCP/MCPTLSManager.swift b/TablePro/Core/MCP/MCPTLSManager.swift index f876fca1a..f0fcf74c6 100644 --- a/TablePro/Core/MCP/MCPTLSManager.swift +++ b/TablePro/Core/MCP/MCPTLSManager.swift @@ -1,8 +1,3 @@ -// -// MCPTLSManager.swift -// TablePro -// - import CryptoKit import Foundation import os diff --git a/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift index 33c45fab6..add6fc5dc 100644 --- a/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift +++ b/TablePro/Core/MCP/Protocol/Handlers/InitializeHandler.swift @@ -55,7 +55,7 @@ public struct InitializeHandler: MCPMethodHandler { "serverInfo": .object([ "name": .string("tablepro"), "title": .string("TablePro"), - "version": .string("1.0.0") + "version": .string(Self.serverVersion) ]) ]) @@ -74,4 +74,8 @@ public struct InitializeHandler: MCPMethodHandler { } return supportedProtocolVersion } + + private static let serverVersion: String = { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" + }() } diff --git a/TablePro/Core/MCP/Session/MCPSessionState.swift b/TablePro/Core/MCP/Session/MCPSessionState.swift index f55ce60f6..419a5f391 100644 --- a/TablePro/Core/MCP/Session/MCPSessionState.swift +++ b/TablePro/Core/MCP/Session/MCPSessionState.swift @@ -11,6 +11,7 @@ public enum MCPSessionTerminationReason: Sendable, Equatable, CustomStringConver case idleTimeout case capacityEvicted case serverShutdown + case tokenRevoked public var description: String { switch self { @@ -22,6 +23,8 @@ public enum MCPSessionTerminationReason: Sendable, Equatable, CustomStringConver return "capacity_evicted" case .serverShutdown: return "server_shutdown" + case .tokenRevoked: + return "token_revoked" } } } diff --git a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift index c44334017..f2d5ac764 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift @@ -24,11 +24,11 @@ public actor MCPHttpServerTransport { private var sseConnectionsBySession: [MCPSessionId: UUID] = [:] private var sessionEventsTask: Task? - private var exchangesContinuation: AsyncStream.Continuation? - private let exchangesStorage: AsyncStream + nonisolated public let exchanges: AsyncStream + nonisolated private let exchangesContinuation: AsyncStream.Continuation - private var stateContinuation: AsyncStream.Continuation? - private let listenerStateStorage: AsyncStream + nonisolated public let listenerState: AsyncStream + nonisolated private let stateContinuation: AsyncStream.Continuation private var currentState: MCPHttpServerState = .idle @@ -43,25 +43,15 @@ public actor MCPHttpServerTransport { self.authenticator = authenticator self.clock = clock - var exchangeContinuation: AsyncStream.Continuation? - self.exchangesStorage = AsyncStream { continuation in - exchangeContinuation = continuation - } - self.exchangesContinuation = exchangeContinuation - - var stateCont: AsyncStream.Continuation? - self.listenerStateStorage = AsyncStream { continuation in - stateCont = continuation - } - self.stateContinuation = stateCont - } - - nonisolated public var exchanges: AsyncStream { - exchangesStorage - } + let (exchanges, exchangesContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingOldest(1024) + ) + self.exchanges = exchanges + self.exchangesContinuation = exchangesContinuation - nonisolated public var listenerState: AsyncStream { - listenerStateStorage + let (listenerState, stateContinuation) = AsyncStream.makeStream() + self.listenerState = listenerState + self.stateContinuation = stateContinuation } public func start() async throws { @@ -129,6 +119,8 @@ public actor MCPHttpServerTransport { } emitState(.stopped) + exchangesContinuation.finish() + stateContinuation.finish() } public func sendNotification(_ notification: JsonRpcNotification, toSession sessionId: MCPSessionId) async { @@ -200,7 +192,7 @@ public actor MCPHttpServerTransport { private func emitState(_ state: MCPHttpServerState) { currentState = state - stateContinuation?.yield(state) + stateContinuation.yield(state) } private func startSessionEventListener() { @@ -223,9 +215,20 @@ public actor MCPHttpServerTransport { return } - if reason == .idleTimeout { - await context.writeRaw(Data("\u{003A} idle-timeout\n\n".utf8)) + let comment: String + switch reason { + case .idleTimeout: + comment = "idle-timeout" + case .tokenRevoked: + comment = "token-revoked" + case .serverShutdown: + comment = "server-shutdown" + case .clientRequested: + comment = "client-disconnect" + case .capacityEvicted: + comment = "capacity-evicted" } + await context.writeRaw(Data("\u{003A} \(comment)\n\n".utf8)) await context.cancel() connections.removeValue(forKey: connectionId) } @@ -356,6 +359,18 @@ public actor MCPHttpServerTransport { return } + guard parsed.code.utf8.count <= 1024, parsed.codeVerifier.utf8.count <= 1024 else { + Self.logger.warning("Integrations exchange field exceeds size cap") + MCPAuditLogger.logPairingExchange( + outcome: .denied, + ip: ip, + details: "field exceeds 1024 bytes" + ) + await context.writePlainJsonError(status: .badRequest, message: "Field exceeds size limit") + await context.cancel() + return + } + let exchange = PairingExchange(code: parsed.code, verifier: parsed.codeVerifier) let outcome: Result = await MainActor.run { do { @@ -579,7 +594,10 @@ public actor MCPHttpServerTransport { context: exchangeContext, responder: responder ) - exchangesContinuation?.yield(exchange) + let yieldResult = exchangesContinuation.yield(exchange) + if case .dropped = yieldResult { + Self.logger.warning("exchanges buffer full, dropped inbound message — dispatcher is falling behind") + } } private func handleDeleteMcp( diff --git a/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift b/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift index be1c795fa..5580cd0ab 100644 --- a/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPStdioMessageTransport.swift @@ -2,8 +2,8 @@ import Foundation public actor MCPStdioMessageTransport: MCPMessageTransport { nonisolated public let inbound: AsyncThrowingStream + nonisolated private let continuation: AsyncThrowingStream.Continuation - nonisolated private let continuationBox: StreamContinuationBox private let writer: StdioWriter private let errorLogger: (any MCPBridgeLogger)? private var readerTask: Task? @@ -14,12 +14,9 @@ public actor MCPStdioMessageTransport: MCPMessageTransport { stdout: FileHandle = .standardOutput, errorLogger: (any MCPBridgeLogger)? = nil ) { - let box = StreamContinuationBox() - let stream = AsyncThrowingStream { continuation in - box.set(continuation) - } - self.continuationBox = box + let (stream, continuation) = AsyncThrowingStream.makeStream() self.inbound = stream + self.continuation = continuation self.writer = StdioWriter(handle: stdout) self.errorLogger = errorLogger @@ -53,17 +50,17 @@ public actor MCPStdioMessageTransport: MCPMessageTransport { let task = readerTask readerTask = nil task?.cancel() - continuationBox.finish() + continuation.finish() } private func startReader(stdin: FileHandle) { if isClosed { return } - let box = continuationBox + let continuation = self.continuation let logger = errorLogger let task = Task.detached(priority: .userInitiated) { [weak self] in - await Self.readLoop(stdin: stdin, box: box, logger: logger) + await Self.readLoop(stdin: stdin, continuation: continuation, logger: logger) await self?.finishStream() } readerTask = task @@ -75,12 +72,12 @@ public actor MCPStdioMessageTransport: MCPMessageTransport { } isClosed = true readerTask = nil - continuationBox.finish() + continuation.finish() } private static func readLoop( stdin: FileHandle, - box: StreamContinuationBox, + continuation: AsyncThrowingStream.Continuation, logger: (any MCPBridgeLogger)? ) async { var buffer = Data() @@ -90,7 +87,7 @@ public actor MCPStdioMessageTransport: MCPMessageTransport { return } if byte == 0x0A { - processLine(buffer, box: box, logger: logger) + processLine(buffer, continuation: continuation, logger: logger) buffer.removeAll(keepingCapacity: true) continue } @@ -98,18 +95,18 @@ public actor MCPStdioMessageTransport: MCPMessageTransport { } } catch { logger?.log(.error, "stdio read failed: \(error)") - box.finish(throwing: MCPTransportError.readFailed(detail: String(describing: error))) + continuation.finish(throwing: MCPTransportError.readFailed(detail: String(describing: error))) return } if !buffer.isEmpty { - processLine(buffer, box: box, logger: logger) + processLine(buffer, continuation: continuation, logger: logger) } } private static func processLine( _ raw: Data, - box: StreamContinuationBox, + continuation: AsyncThrowingStream.Continuation, logger: (any MCPBridgeLogger)? ) { var trimmed = raw @@ -122,7 +119,7 @@ public actor MCPStdioMessageTransport: MCPMessageTransport { do { let message = try JsonRpcCodec.decode(trimmed) - box.yield(message) + continuation.yield(message) } catch { logger?.log(.warning, "stdio: skipping malformed JSON-RPC line: \(error)") } @@ -141,31 +138,3 @@ private actor StdioWriter { try? handle.synchronize() } } - -final class StreamContinuationBox: Sendable { - nonisolated(unsafe) private var continuation: AsyncThrowingStream.Continuation? - private let lock = NSLock() - - func set(_ continuation: AsyncThrowingStream.Continuation) { - lock.withLock { - self.continuation = continuation - } - } - - func yield(_ message: JsonRpcMessage) { - lock.withLock { - continuation?.yield(message) - } - } - - func finish(throwing error: Error? = nil) { - lock.withLock { - if let error { - continuation?.finish(throwing: error) - } else { - continuation?.finish() - } - continuation = nil - } - } -} diff --git a/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift b/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift index a47f0ed7f..31ee6b1b3 100644 --- a/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift @@ -26,12 +26,11 @@ public struct MCPStreamableHttpClientConfiguration: Sendable { public actor MCPStreamableHttpClientTransport: MCPMessageTransport { nonisolated public let inbound: AsyncThrowingStream + nonisolated private let continuation: AsyncThrowingStream.Continuation - nonisolated private let continuationBox: StreamContinuationBox private let configuration: MCPStreamableHttpClientConfiguration private let urlSession: URLSession private let errorLogger: (any MCPBridgeLogger)? - private let writer: HttpWriter private var sessionId: String? private var isClosed = false private var serverInitiatedStreamOpen = false @@ -45,12 +44,9 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { self.configuration = configuration self.errorLogger = errorLogger - let box = StreamContinuationBox() - let stream = AsyncThrowingStream { continuation in - box.set(continuation) - } - self.continuationBox = box + let (stream, continuation) = AsyncThrowingStream.makeStream() self.inbound = stream + self.continuation = continuation if let urlSession { self.urlSession = urlSession @@ -65,8 +61,6 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { self.urlSession = URLSession(configuration: config) } } - - writer = HttpWriter() } public func send(_ message: JsonRpcMessage) async throws { @@ -116,7 +110,7 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { task.cancel() } urlSession.invalidateAndCancel() - continuationBox.finish() + continuation.finish() } private func trackTask(_ task: Task) { @@ -134,9 +128,7 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { private func dispatch(body: Data, requestId: JsonRpcId?) async { do { - try await writer.serialize { - try await self.performRequest(body: body, requestId: requestId) - } + try await performRequest(body: body, requestId: requestId) } catch { await handleSendError(error: error, requestId: requestId) } @@ -271,7 +263,7 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { } do { let message = try JsonRpcCodec.decode(payload) - continuationBox.yield(message) + continuation.yield(message) } catch { errorLogger?.log(.warning, "SSE: skipping malformed JSON-RPC frame: \(error)") } @@ -280,12 +272,12 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { private func pushJsonBody(_ data: Data, fallbackId: JsonRpcId?) { do { let message = try JsonRpcCodec.decode(data) - continuationBox.yield(message) + continuation.yield(message) } catch { errorLogger?.log(.warning, "HTTP: malformed JSON-RPC body: \(error)") let synthetic = MCPProtocolError.parseError(detail: String(describing: error)) .toJsonRpcErrorResponse(id: fallbackId) - continuationBox.yield(.errorResponse(synthetic)) + continuation.yield(.errorResponse(synthetic)) } } @@ -302,11 +294,11 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { if !body.isEmpty, let parsed = try? JsonRpcCodec.decode(body) { if case .errorResponse = parsed { - continuationBox.yield(parsed) + continuation.yield(parsed) return } if case .successResponse = parsed { - continuationBox.yield(parsed) + continuation.yield(parsed) return } } @@ -314,7 +306,7 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { let challenge = headerValue(headers, name: "WWW-Authenticate") ?? "Bearer realm=\"TablePro\"" let protocolError = Self.protocolError(forStatus: status, body: body, challenge: challenge) let response = protocolError.toJsonRpcErrorResponse(id: requestId) - continuationBox.yield(.errorResponse(response)) + continuation.yield(.errorResponse(response)) } private func handleSendError(error: Error, requestId: JsonRpcId?) async { @@ -327,7 +319,7 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { } let protocolError = MCPProtocolError.internalError(detail: String(describing: error)) let response = protocolError.toJsonRpcErrorResponse(id: requestId) - continuationBox.yield(.errorResponse(response)) + continuation.yield(.errorResponse(response)) } private func captureSessionIdIfPresent(from response: HTTPURLResponse) { @@ -386,30 +378,6 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { } } -private actor HttpWriter { - private var pending: Task? - - func serialize(_ work: @Sendable @escaping () async throws -> T) async throws -> T { - let previous = pending - let result: Result = await withCheckedContinuation { continuation in - let task = Task { [previous] in - _ = await previous?.value - do { - let value = try await work() - continuation.resume(returning: .success(value)) - } catch { - continuation.resume(returning: .failure(error)) - } - } - self.pending = task - } - switch result { - case .success(let value): return value - case .failure(let error): throw error - } - } -} - private final class CertificatePinningDelegate: NSObject, URLSessionDelegate { private let expectedFingerprint: String private let errorLogger: (any MCPBridgeLogger)? diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 90a780475..212c2afaf 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -10565,6 +10565,9 @@ } } } + }, + "Confirm Destructive Operation" : { + }, "Confirm passphrase" : { "localizations" : { @@ -13841,6 +13844,9 @@ }, "Database name (uses connection's current database if omitted)" : { + }, + "Database name (uses current if omitted)" : { + }, "Database name to switch to" : { @@ -15321,6 +15327,9 @@ } } } + }, + "Describe Table" : { + }, "Description" : { "localizations" : { @@ -16609,6 +16618,9 @@ } } } + }, + "Earliest executed_at to include, Unix epoch seconds (inclusive, optional)" : { + }, "Edit" : { "localizations" : { @@ -18941,7 +18953,6 @@ } }, "Export Data" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -21454,6 +21465,9 @@ } } } + }, + "Focus Query Tab" : { + }, "Focus the query editor to insert" : { "localizations" : { @@ -22094,6 +22108,9 @@ } } } + }, + "Get Connection Status" : { + }, "Get detailed status of a database connection" : { @@ -22166,6 +22183,9 @@ } } } + }, + "Get Table DDL" : { + }, "Get the CREATE TABLE DDL statement for a table" : { @@ -23662,6 +23682,9 @@ } } } + }, + "Include approximate row counts (default false)" : { + }, "Include column headers" : { "extractionState" : "stale", @@ -25993,6 +26016,9 @@ } } } + }, + "Latest executed_at to include, Unix epoch seconds (inclusive, optional)" : { + }, "Layout" : { "extractionState" : "stale", @@ -26555,15 +26581,30 @@ }, "List all saved database connections with their status" : { + }, + "List Connections" : { + }, "List currently open tabs across all TablePro windows. Returns connection, tab type, table name, and titles for each tab." : { + }, + "List Databases" : { + }, "List of all saved database connections with metadata" : { + }, + "List Recent Tabs" : { + + }, + "List Schemas" : { + }, "List schemas in a database" : { + }, + "List Tables" : { + }, "List tables and views in a database" : { @@ -27572,6 +27613,9 @@ } } } + }, + "Maximum number of entries to return (default 50, max 500)" : { + }, "Maximum row limit" : { "localizations" : { @@ -31711,6 +31755,9 @@ } } } + }, + "Only affects new saves. Re-save a password to update its sync." : { + }, "Open" : { "localizations" : { @@ -31827,6 +31874,9 @@ } } } + }, + "Open Connection Window" : { + }, "Open containing folder" : { "localizations" : { @@ -32253,6 +32303,9 @@ } } } + }, + "Open Table Tab" : { + }, "Open Terminal" : { "localizations" : { @@ -37923,6 +37976,9 @@ } } } + }, + "Restrict to a specific connection (UUID, optional)" : { + }, "Results" : { "localizations" : { @@ -39466,6 +39522,9 @@ }, "Schema name (for multi-schema databases)" : { + }, + "Schema name (uses current if omitted)" : { + }, "Schema name to switch to" : { @@ -39757,6 +39816,9 @@ } } } + }, + "Search Query History" : { + }, "Search saved query history. Returns matching entries with execution time, row count, and outcome." : { @@ -39826,6 +39888,9 @@ } } } + }, + "Search text (full-text matched against the query column)" : { + }, "Search..." : { "localizations" : { @@ -44373,6 +44438,9 @@ } } } + }, + "Switch Schema" : { + }, "Switch the active database on a connection" : { @@ -45042,6 +45110,9 @@ } } } + }, + "Table name" : { + }, "Table Name" : { "extractionState" : "stale", From ac7ee465a8872417019d4ff1f31e9a4eda1e93e7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 3 May 2026 19:55:39 +0700 Subject: [PATCH 53/54] docs(mcp): document 2025-11-25 features and fix broken external-api links --- docs/README.md | 4 ++-- docs/customization/settings.mdx | 2 +- docs/databases/overview.mdx | 2 +- docs/development/architecture.mdx | 30 ++++++++++++++++++++++++++++++ docs/external-api/mcp-tools.mdx | 10 +++++++++- docs/features/ai-assistant.mdx | 2 +- docs/features/mcp.mdx | 2 +- docs/features/safe-mode.mdx | 2 +- 8 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/README.md b/docs/README.md index 2080ab608..db275c528 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,8 +13,8 @@ docs/ ├── databases/ # Database connection guides ├── features/ # Feature documentation ├── customization/ # Settings and customization -├── development/ # Developer documentation -└── vi/ # Vietnamese translation (full parity) +├── external-api/ # URL scheme, MCP, pairing +└── development/ # Developer documentation ``` ## Local Development diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 96339fc5d..14047429d 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -76,7 +76,7 @@ A tab is "clean" when it's a table tab (not query/create), unpinned, no unsaved ## MCP -The **MCP** tab covers the [External API](/external-api) surface. The server lazy-starts on first use and exposes the following sections: +The **MCP** tab covers the [External API](/external-api/index) surface. The server lazy-starts on first use and exposes the following sections: - **MCP Server**: enable toggle and live status. The server runs on a free port in the `51000-52000` range. See [MCP Server](/features/mcp). - **MCP Configuration**: row limit defaults, query timeout, "log MCP queries in history". diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index bf0a7a8f0..547e9190f 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -250,7 +250,7 @@ Open the **Advanced** tab on the connection form for the following settings: | Field | Description | |-------|-------------| | **Startup Commands** | SQL statements that run automatically on every connection. See [Startup Commands](#startup-commands). | -| **External Access** | Controls how external clients (Raycast, Cursor, Claude Desktop) reach this connection: `blocked`, `readOnly` (default), or `readWrite`. Tokens cannot exceed this level. See [External API](/external-api). | +| **External Access** | Controls how external clients (Raycast, Cursor, Claude Desktop) reach this connection: `blocked`, `readOnly` (default), or `readWrite`. Tokens cannot exceed this level. See [External API](/external-api/index). | | **Local only** | Excludes this connection from iCloud Sync. See [iCloud Sync](/features/icloud-sync). | | **Plugin fields** | Driver-specific options (for example, MongoDB `replicaSet`, ClickHouse `Secure`). | diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index 745c434b4..559cc4b7f 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -118,6 +118,36 @@ flowchart LR - **SQLContextAnalyzer**: parses cursor position context (table ref, column ref, keyword) - **SQLSchemaProvider**: actor that caches and serves schema data +### MCP Layer + +The MCP server lives under `Core/MCP/` and is split into five horizontal layers. Each layer talks only to the layer below it. + +```mermaid +flowchart TD + Wire["Wire (Codable values)
JsonRpcMessage, JsonRpcCodec, HttpRequestParser, SseDecoder"] + Transport["Transport (NWListener, URLSession, FileHandle)
MCPHttpServerTransport, MCPStdioMessageTransport, MCPStreamableHttpClientTransport"] + Session["Session / Auth / RateLimit (actors)
MCPSessionStore, MCPBearerTokenAuthenticator, MCPRateLimiter"] + Protocol["Protocol (dispatcher + handlers)
MCPProtocolDispatcher, 19 tools, MCPProgressEmitter"] + Bridge["Bridge (tablepro-mcp CLI)
BridgeProxy: stdio <-> HTTP"] + + Bridge --> Wire + Transport --> Wire + Session --> Transport + Protocol --> Session +``` + +**Wire**: pure Codable types, no I/O. JSON-RPC 2.0, strict-CRLF HTTP, SSE encoder/decoder. + +**Transport**: HTTP server uses `NWListener` and binds to `127.0.0.1:` by default. The stream endpoints (`exchanges`, `listenerState`) are bounded `AsyncStream`s consumed by `MCPServerManager`. The bridge's client-side transport uses `URLSession.bytes(for:)` for incremental SSE. + +**Session**: `MCPSessionStore` is an actor that owns session lifecycle. Idle timeout is 15 minutes. Token revocation marks sessions with `.tokenRevoked` and the SSE stream emits a typed terminate comment so clients can distinguish revoke from network blip. + +**Protocol**: `MCPProtocolDispatcher` spawns a child `Task` per inbound exchange, so two concurrent tool calls run in parallel instead of queueing on the dispatcher actor. Per-request cancellation flows through `MCPInflightRegistry`. Long-running tools emit `notifications/progress` to clients that pass `_meta.progressToken`. + +**Bridge**: `tablepro-mcp` is a 50-line composition root. `MCPStdioMessageTransport` host-side, `MCPStreamableHttpClientTransport` upstream. Errors land in os_log and stderr. The host-facing transport writes only validated `JsonRpcMessage` bytes to stdout. + +The server accepts protocol versions `2025-03-26`, `2025-06-18`, and `2025-11-25`. See [Versioning](/external-api/versioning) for the negotiation rules and the additive-within-major-version stability policy. + ## Data Flow ### Connection diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index adc5af82b..86dd34b76 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -14,7 +14,15 @@ The same tool catalog is available over two transports: - **HTTP**: MCP Streamable HTTP at `http://127.0.0.1:/mcp` (port from the handshake file). POST for JSON-RPC requests, GET for the SSE stream that carries server-initiated notifications. Bearer token in `Authorization` header. - **stdio**: bundled `tablepro-mcp` CLI bridges stdio JSON-RPC to localhost HTTP. No token needed because the bridge reuses the in-app handshake. -The server reports `protocolVersion: "2025-03-26"` from `initialize`. See [MCP Clients](/external-api/mcp-clients) for stdio config snippets. +The server accepts `2025-03-26`, `2025-06-18`, and `2025-11-25`. On `initialize` it echoes whichever version the client requested. If the client asks for something else, the server returns `2025-11-25`. See [Versioning](/external-api/versioning). + +## What 2025-11-25 adds + +Clients on the latest spec see three things that older clients don't: + +- **Structured tool output**. Every tool that returns data fills `structuredContent` next to `content[]`. Older clients keep parsing the JSON text in `content[0].text`. Newer clients can read the typed object directly. Applies to `list_*`, `describe_table`, `get_table_ddl`, `get_connection_status`, `list_recent_tabs`, `search_query_history`, `execute_query`, and `confirm_destructive_operation`. +- **Tool annotations**. `tools/list` returns `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` per tool. Read tools advertise `readOnlyHint=true`. `confirm_destructive_operation` advertises `destructiveHint=true`. `execute_query` and `export_data` advertise `openWorldHint=true`. +- **Streaming progress**. Long-running tool calls emit `notifications/progress` events when the client passes a `_meta.progressToken` in the request. Today this fires on `execute_query` at four stages: Connecting, Executing, Formatting result, Done. ## Scope and access matrix diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index fb773b2d8..cbe358c83 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -194,7 +194,7 @@ Set a per-connection AI policy in the connection form: **Use Default**, **Always ### External AI clients -External clients (Raycast, Cursor, Claude Desktop, and other MCP clients) call the same AI tools through the [External API](/external-api). Two per-connection settings gate them: +External clients (Raycast, Cursor, Claude Desktop, and other MCP clients) call the same AI tools through the [External API](/external-api/index). Two per-connection settings gate them: - **AI policy** decides whether the connection is reachable by AI clients at all. `Never` blocks every external AI tool call against this connection. - **External Access** caps the level: `blocked`, `readOnly` (default), or `readWrite`. A token's effective permission is `MIN(token.scope, connection.externalAccess)`. Set this in the connection form's **Advanced** tab. diff --git a/docs/features/mcp.mdx b/docs/features/mcp.mdx index 8120c91d1..e69c7c406 100644 --- a/docs/features/mcp.mdx +++ b/docs/features/mcp.mdx @@ -5,7 +5,7 @@ description: Built-in Model Context Protocol server that exposes TablePro to AI # MCP Server -TablePro includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets AI clients query your databases through TablePro's saved connections. The MCP server is one of three pillars of the [External API](/external-api), alongside the URL scheme and the pairing flow. +TablePro includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets AI clients query your databases through TablePro's saved connections. The MCP server is one of three pillars of the [External API](/external-api/index), alongside the URL scheme and the pairing flow. This page covers the in-app **Settings > Integrations** UI. For protocol details, see the External API section. diff --git a/docs/features/safe-mode.mdx b/docs/features/safe-mode.mdx index 28db4e788..f2da132ee 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -81,5 +81,5 @@ A write request from an external client clears three locks in this order: 2. **Token scope** (per-integration, `readOnly` / `readWrite` / `fullAccess`). Issued by the [pairing flow](/external-api/pairing) and bounded by External Access: effective permission is `MIN(token.scope, connection.externalAccess)`. 3. **Safe Mode** (per-query). The same rules on this page apply once the request has been routed to the connection. Touch ID prompts and confirmation dialogs still appear, even for queries originating from an external client. -DROP and TRUNCATE always need an explicit confirmation phrase via the `confirm_destructive_operation` tool, regardless of token scope. See [External API security model](/external-api#security-model). +DROP and TRUNCATE always need an explicit confirmation phrase via the `confirm_destructive_operation` tool, regardless of token scope. See [External API security model](/external-api/index#security-model). From 14169d6bd3640ce4313f10223ff9f67f954b03d2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 3 May 2026 19:55:47 +0700 Subject: [PATCH 54/54] Update .gitignore --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 717f3e3a9..c525d8208 100644 --- a/.gitignore +++ b/.gitignore @@ -150,6 +150,3 @@ Libs/*.a Libs/.downloaded Libs/dylibs/ Libs/ios/ - -# Local refactor scratchpad (per chore: untrack docs/refactor scratchpad) -docs/refactor/