diff --git a/CHANGELOG.md b/CHANGELOG.md index 222cf3ae85..65fdf95b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Settings > Editor grouped into Display, Gutter and Editing. - Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data, and kept per Mac. - Editor font size range of 10 to 24 points everywhere, including zoom. +- MCP server runs only while Enable MCP Server is on in Settings > MCP. Clients and pairing no longer start it while it is off. - Edit > Data shown only in data file windows. - Delta updates from the last five releases, about 6 MB instead of the full 34 MB download. @@ -45,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - JSON and PHP tree values colored differently from the same values in the row inspector. - Autocomplete icon colors ignoring the theme. - Theme color wells and the theme preview unreadable to VoiceOver. +- Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background. - GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed. ### Security diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 32651b5b9e..f886cc1720 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -212,7 +212,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func persistOpenConnectionsForRecovery() { - LastOpenConnectionsStorage.shared.save(connectionIds: SessionRecoveryTracker.connectionIds()) + let connectionIds = SessionRecoveryTracker.connectionIds() + let origin = AppActivationPolicyController.shared.origin + guard RecoveryConnectionList.shouldSaveOnQuit(connectionIds, origin: origin) else { return } + LastOpenConnectionsStorage.shared.save(connectionIds: connectionIds) } @objc func handleSystemDidWake(_ notification: Notification) { diff --git a/TablePro/CLI/BridgeMain.swift b/TablePro/CLI/BridgeMain.swift index c17264d2dd..da643811eb 100644 --- a/TablePro/CLI/BridgeMain.swift +++ b/TablePro/CLI/BridgeMain.swift @@ -65,6 +65,8 @@ struct TableProMcpBridge { return "The local MCP endpoint did not prove it belongs to TablePro. Restart TablePro and try again." case MCPBridgeStartupError.unsupportedUpstreamVersion: return "This TablePro build does not speak \(MCPProtocolVersion.latest.rawValue). Update TablePro." + case MCPHandshakeAcquisitionError.serverTurnedOff: + return error.localizedDescription default: return "TablePro is not running. Launch the app and enable the MCP server." } diff --git a/TablePro/CLI/Handshake.swift b/TablePro/CLI/Handshake.swift index 71c5957ff2..dd95bde572 100644 --- a/TablePro/CLI/Handshake.swift +++ b/TablePro/CLI/Handshake.swift @@ -1,9 +1,10 @@ import Foundation import os -enum MCPHandshakeAcquisitionError: Error, LocalizedError { +enum MCPHandshakeAcquisitionError: Error, LocalizedError, Equatable { case launchFailed(status: Int32) case timeout(lastFailure: String) + case serverTurnedOff var errorDescription: String? { switch self { @@ -11,29 +12,42 @@ enum MCPHandshakeAcquisitionError: Error, LocalizedError { return "Failed to launch TablePro (open exit \(status))" case .timeout(let lastFailure): return "Timed out waiting for a trusted TablePro MCP handshake (\(lastFailure))" + case .serverTurnedOff: + return "The TablePro MCP server is turned off. Turn it on in TablePro under Settings > MCP." } } } 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 handshakeUrl: URL + let refusalUrl: URL let logger: any MCPBridgeLogger + private let pollTimeout: Duration + private let launchHostApp: @Sendable (any MCPBridgeLogger) throws -> Void - init(logger: any MCPBridgeLogger, supportDirectory: URL = MCPHandshakeLocation.supportDirectory()) { + init( + logger: any MCPBridgeLogger, + supportDirectory: URL = MCPHandshakeLocation.supportDirectory(), + pollTimeout: Duration = .seconds(10), + launchHostApp: @escaping @Sendable (any MCPBridgeLogger) throws -> Void = { try openHostApp(logger: $0) } + ) { self.handshakeUrl = MCPHandshakeLocation.file(in: supportDirectory) + self.refusalUrl = MCPHandshakeLocation.refusalFile(in: supportDirectory) self.logger = logger + self.pollTimeout = pollTimeout + self.launchHostApp = launchHostApp } func acquire() async throws -> MCPHandshakePayload { if let trusted = loadTrusted().payload { return trusted } - try launchHostApp() - return try await pollForTrustedHandshake() + let launchRequestedAt = Date() + try launchHostApp(logger) + return try await pollForTrustedHandshake(launchRequestedAt: launchRequestedAt) } private func loadTrusted() -> (payload: MCPHandshakePayload?, failure: String) { @@ -46,10 +60,20 @@ struct MCPHandshakeAcquirer: Sendable { } } + /// A forged refusal can only make the bridge give up, so it is read without the handshake's + /// ownership checks. + private func refusalAnswers(launchRequestedAt: Date) -> Bool { + guard let data = try? Data(contentsOf: refusalUrl), + let refusal = try? MCPHandshakeCoding.decoder().decode(MCPStartRefusal.self, from: data) else { + return false + } + return refusal.answers(launchRequestedAt: launchRequestedAt) + } + /// `open` hands its own environment to the app it launches, which is how the flag arrives early /// enough for the app to stay out of the Dock and the app switcher. Nobody asked for this window /// of TablePro: a client did, to answer a question. - private func launchHostApp() throws { + static func openHostApp(logger: any MCPBridgeLogger) throws { logger.log(.info, "No trusted MCP handshake; launching TablePro via \(Self.launchUrl)") let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") @@ -64,14 +88,18 @@ struct MCPHandshakeAcquirer: Sendable { } } - private func pollForTrustedHandshake() async throws -> MCPHandshakePayload { - let deadline = ContinuousClock().now.advanced(by: Self.pollTimeout) + private func pollForTrustedHandshake(launchRequestedAt: Date) async throws -> MCPHandshakePayload { + let deadline = ContinuousClock().now.advanced(by: pollTimeout) var lastFailure = "no handshake file" while ContinuousClock().now < deadline { let attempt = loadTrusted() if let payload = attempt.payload { return payload } + if refusalAnswers(launchRequestedAt: launchRequestedAt) { + logger.log(.error, "TablePro refused to start the MCP server: it is turned off") + throw MCPHandshakeAcquisitionError.serverTurnedOff + } lastFailure = attempt.failure try? await Task.sleep(for: Self.pollInterval) } diff --git a/TablePro/Core/MCP/Lifecycle/MCPHandshakeFile.swift b/TablePro/Core/MCP/Lifecycle/MCPHandshakeFile.swift index 7f667a168a..eb4f91303a 100644 --- a/TablePro/Core/MCP/Lifecycle/MCPHandshakeFile.swift +++ b/TablePro/Core/MCP/Lifecycle/MCPHandshakeFile.swift @@ -39,6 +39,24 @@ public struct MCPHandshakePayload: Codable, Sendable, Equatable { } } +public struct MCPStartRefusal: Codable, Sendable, Equatable { + public let pid: Int32 + /// Seconds since 1970 with the fraction kept: an ISO 8601 date drops it, and a refusal from + /// earlier in the same second would then answer a launch it never saw. + public let refusedAt: TimeInterval + + public init(pid: Int32, refusedAt: Date) { + self.pid = pid + self.refusedAt = refusedAt.timeIntervalSince1970 + } + + /// Only a refusal written after the launch answers it, so one left from an earlier run cannot + /// turn a client away once MCP is on again. + public func answers(launchRequestedAt: Date) -> Bool { + refusedAt >= launchRequestedAt.timeIntervalSince1970 + } +} + public enum MCPHandshakeCoding { public static func encoder() -> JSONEncoder { let encoder = JSONEncoder() @@ -56,12 +74,17 @@ public enum MCPHandshakeCoding { public enum MCPHandshakeLocation { public static let fileName = "mcp-handshake.json" + public static let refusalFileName = "mcp-start-refused.json" public static let sandboxEnvironmentVariable = "TABLEPRO_UI_TEST_SANDBOX" public static func file(in supportDirectory: URL) -> URL { supportDirectory.appendingPathComponent(fileName, isDirectory: false) } + public static func refusalFile(in supportDirectory: URL) -> URL { + supportDirectory.appendingPathComponent(refusalFileName, isDirectory: false) + } + public static func supportDirectory( environment: [String: String] = ProcessInfo.processInfo.environment ) -> URL { @@ -257,32 +280,50 @@ internal final class MCPHandshakeFile { MCPHandshakeLocation.file(in: directory) } + internal var refusalUrl: URL { + MCPHandshakeLocation.refusalFile(in: directory) + } + internal func write(_ payload: MCPHandshakePayload) { do { - try prepareDirectory() - let data = try MCPHandshakeCoding.encoder().encode(payload) - let staging = directory.appendingPathComponent( - ".\(MCPHandshakeLocation.fileName).\(UUID().uuidString)", - isDirectory: false - ) - guard fileManager.createFile( - atPath: staging.path, - contents: data, - attributes: [.posixPermissions: 0o600] - ) else { - Self.logger.error("Could not stage the MCP handshake file") - return - } - if fileManager.fileExists(atPath: url.path) { - try fileManager.removeItem(at: url) - } - try fileManager.moveItem(at: staging, to: url) + try replace(url, with: MCPHandshakeCoding.encoder().encode(payload)) Self.logger.info("Wrote the MCP handshake file at \(self.url.path, privacy: .private(mask: .hash))") } catch { Self.logger.error("Failed to write the MCP handshake file: \(error.publicLogShape, privacy: .public)") } } + internal func writeRefusal(_ refusal: MCPStartRefusal) { + do { + try replace(refusalUrl, with: MCPHandshakeCoding.encoder().encode(refusal)) + } catch { + Self.logger.error("Failed to write the MCP start refusal: \(error.publicLogShape, privacy: .public)") + } + } + + internal func removeRefusal() { + remove(refusalUrl) + } + + private func replace(_ destination: URL, with data: Data) throws { + try prepareDirectory() + let staging = directory.appendingPathComponent( + ".\(destination.lastPathComponent).\(UUID().uuidString)", + isDirectory: false + ) + guard fileManager.createFile( + atPath: staging.path, + contents: data, + attributes: [.posixPermissions: 0o600] + ) else { + throw CocoaError(.fileWriteUnknown) + } + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + try fileManager.moveItem(at: staging, to: destination) + } + internal func removeIfWrittenByThisProcess(instanceId: String) { guard let payload = readWithoutTrustChecks() else { return } guard payload.pid == ProcessInfo.processInfo.processIdentifier, @@ -303,12 +344,16 @@ internal final class MCPHandshakeFile { } private func remove() { + remove(url) + } + + private func remove(_ file: URL) { do { - try fileManager.removeItem(at: url) + try fileManager.removeItem(at: file) } catch CocoaError.fileNoSuchFile { return } catch { - Self.logger.error("Failed to delete the handshake file: \(error.publicLogShape, privacy: .public)") + Self.logger.error("Failed to delete \(file.lastPathComponent, privacy: .public): \(error.publicLogShape, privacy: .public)") } } diff --git a/TablePro/Core/MCP/Lifecycle/MCPServerLifecycleQueue.swift b/TablePro/Core/MCP/Lifecycle/MCPServerLifecycleQueue.swift index 8eb29638b5..8580804afc 100644 --- a/TablePro/Core/MCP/Lifecycle/MCPServerLifecycleQueue.swift +++ b/TablePro/Core/MCP/Lifecycle/MCPServerLifecycleQueue.swift @@ -17,8 +17,15 @@ internal final class MCPServerLifecycleQueue { return task } - internal func run(_ work: @escaping @MainActor () async -> Void) async { - await enqueue(work).value + @discardableResult + internal func run(_ work: @escaping @MainActor () async -> Value) async -> Value { + let previous = tail + let task = Task { @MainActor in + await previous?.value + return await work() + } + tail = Task { @MainActor in _ = await task.value } + return await task.value } internal func drain() async { diff --git a/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift b/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift index 1cf06339ba..6b21ce56ee 100644 --- a/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift +++ b/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift @@ -9,6 +9,17 @@ internal enum MCPServerState: Sendable, Equatable { case failed(String) } +internal enum MCPServerStartError: Error, LocalizedError, Equatable { + case turnedOff + + internal var errorDescription: String? { + switch self { + case .turnedOff: + String(localized: "The MCP server is turned off. Turn it on in Settings > MCP.") + } + } +} + @MainActor internal final class MCPServerManager: ObservableObject { internal struct SessionSnapshot: Sendable, Identifiable { @@ -40,6 +51,7 @@ internal final class MCPServerManager: ObservableObject { private let lifecycle = MCPServerLifecycleQueue() private let handshake: MCPHandshakeFile + private let isEnabled: @MainActor () -> Bool @Published private var composition: MCPServerComposition? @Published private var bridgeCredential: BridgeCredential? @@ -61,15 +73,20 @@ internal final class MCPServerManager: ObservableObject { return nil } - private init(handshake: MCPHandshakeFile? = nil) { + internal init( + handshake: MCPHandshakeFile? = nil, + isEnabled: @escaping @MainActor () -> Bool = { AppSettingsManager.shared.mcp.enabled } + ) { self.handshake = handshake ?? MCPHandshakeFile(directory: AppStorageEnvironment.shared.supportDirectory) + self.isEnabled = isEnabled } - internal func start(port: UInt16) async { - await lifecycle.run { [weak self] in + internal func start(port: UInt16) async throws { + let refusal = await lifecycle.run { [weak self] in await self?.performStart(port: port) } + if let refusal { throw refusal } } internal func stop() async { @@ -78,25 +95,13 @@ internal final class MCPServerManager: ObservableObject { } } - internal func restart(port: UInt16) async { - await lifecycle.run { [weak self] in - guard let self else { return } - await self.performStop() - await self.performStart(port: port) - } - } - - internal func lazyStart() async { - await lifecycle.run { [weak self] in - guard let self, !self.isRunning else { return } - await self.performStart(port: UInt16(clamping: AppSettingsManager.shared.mcp.port)) - } - } - - internal func scheduleStart(port: UInt16) { - lifecycle.enqueue { [weak self] in - await self?.performStart(port: port) + internal func lazyStart() async throws { + let refusal = await lifecycle.run { [weak self] () async -> MCPServerStartError? in + guard let self else { return nil } + if self.isRunning, self.isEnabled() { return nil } + return await self.performStart(port: UInt16(clamping: AppSettingsManager.shared.mcp.port)) } + if let refusal { throw refusal } } internal func scheduleStop() { @@ -147,7 +152,18 @@ internal final class MCPServerManager: ObservableObject { await refreshClients() } - private func performStart(port: UInt16) async { + /// The one check every start path reaches, so no caller can bind a listener while MCP is off. + @discardableResult + private func performStart(port: UInt16) async -> MCPServerStartError? { + guard isEnabled() else { + await performStop() + handshake.writeRefusal( + MCPStartRefusal(pid: ProcessInfo.processInfo.processIdentifier, refusedAt: Date()) + ) + Self.logger.info("Refused to start the MCP server: it is turned off") + return .turnedOff + } + if composition != nil { await performStop() } @@ -176,16 +192,17 @@ internal final class MCPServerManager: ObservableObject { } guard self.generation == generation else { await built.transport.stop() - return + return nil } await activate(built, generation: generation, store: store) - return + return nil } let failure = MCPPortAllocatorError.everyCandidateRejected(lastFailure: lastFailure) Self.logger.error("MCP server failed to start: \(lastFailure, privacy: .public)") state = .failed(failure.localizedDescription) tokenStore = nil + return nil } private func activate(_ built: MCPServerComposition, generation: Int, store: MCPTokenStore) async { @@ -215,6 +232,7 @@ internal final class MCPServerManager: ObservableObject { await registerRevocationObserver(built, store: store, generation: generation) writeHandshake(port: boundPort, credential: credential) + handshake.removeRefusal() state = .running(port: boundPort) MCPAuditLogger.logServerStarted(port: boundPort) } diff --git a/TablePro/Core/MCP/MCPPairingService.swift b/TablePro/Core/MCP/MCPPairingService.swift index 778cc731ea..74536932b7 100644 --- a/TablePro/Core/MCP/MCPPairingService.swift +++ b/TablePro/Core/MCP/MCPPairingService.swift @@ -109,16 +109,19 @@ final class MCPPairingService { let store: PairingExchangeStore private let rateLimiter: MCPRateLimiter + private let serverManager: MCPServerManager private var pruneTask: Task? init( store: PairingExchangeStore = PairingExchangeStore(), rateLimiter: MCPRateLimiter = MCPRateLimiter( pairingPolicy: MCPPairingService.exchangeRateLimitPolicy - ) + ), + serverManager: MCPServerManager = .shared ) { self.store = store self.rateLimiter = rateLimiter + self.serverManager = serverManager startPruneLoop() } @@ -142,9 +145,9 @@ final class MCPPairingService { throw DatabaseAccessError.invalidArgument(error.localizedMessage) } - await MCPServerManager.shared.lazyStart() + try await serverManager.lazyStart() - guard let tokenStore = MCPServerManager.shared.tokenStore else { + guard let tokenStore = serverManager.tokenStore else { Self.logger.error("Token store unavailable after lazyStart") throw DatabaseAccessError.dataSourceError("Token store unavailable") } diff --git a/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift b/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift index 7f356f2481..40ec13af1c 100644 --- a/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift +++ b/TablePro/Core/Services/Infrastructure/AppActivationPolicyController.swift @@ -79,6 +79,14 @@ internal final class AppActivationPolicyController { apply(.foreground) } + /// A process a machine launched that has nothing on screen for the person. + internal var isIdleMachineSession: Bool { + AppActivationPolicyDecision.role( + origin: origin, + hasUserFacingWindow: hasUserFacingWindow(excluding: nil) + ) == .background + } + /// A closing window is still visible and still in `NSApp.windows` while `willCloseNotification` /// runs, so the window that prompted the recount is excluded from it. internal func reevaluate(excluding closingWindow: NSWindow? = nil) { diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift index 398c968f5a..abc86aed70 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift @@ -42,7 +42,7 @@ internal final class LaunchIntentRouter { try await MCPPairingService.shared.startPairing(request) case .startMCPServer: - await MCPServerManager.shared.lazyStart() + await startMCPServer() case .openSettings(let pane): WindowOpener.shared.openSettings(tab: pane) @@ -78,6 +78,18 @@ internal final class LaunchIntentRouter { } } + /// No alert on refusal: the bridge reads the refusal file and tells its client, and an alert + /// would greet every client that tries while MCP is off. + private func startMCPServer() async { + do { + try await MCPServerManager.shared.lazyStart() + } catch { + Self.logger.info("start-mcp refused: \(error.publicLogShape, privacy: .public)") + guard AppActivationPolicyController.shared.isIdleMachineSession else { return } + NSApp.terminate(nil) + } + } + private func installPlugin(_ url: URL) async throws { let entry = try await PluginManager.shared.installPlugin(from: url) Self.logger.info("Installed plugin '\(entry.name, privacy: .public)' from Finder") @@ -150,6 +162,18 @@ internal final class LaunchIntentRouter { title = String(localized: "Action Failed") } if presentRecoverableError(error, for: intent, title: title) { return } + if error is MCPServerStartError { + AlertHelper.showRecoverableErrorSheet( + title: title, + message: error.localizedDescription, + recoverySuggestion: nil, + recoveryTitle: String(localized: "Open MCP Settings"), + window: NSApp.keyWindow + ) { + WindowOpener.shared.openSettings(tab: .mcp) + } + return + } AlertHelper.showErrorSheet( title: title, message: error.localizedDescription, diff --git a/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift b/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift index 05a2983e24..74036ed598 100644 --- a/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift +++ b/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift @@ -50,6 +50,6 @@ internal enum PostLaunchWork { let mcp = AppSettingsManager.shared.mcp guard mcp.enabled else { return } - Task { await MCPServerManager.shared.start(port: UInt16(clamping: mcp.port)) } + Task { try? await MCPServerManager.shared.start(port: UInt16(clamping: mcp.port)) } } } diff --git a/TablePro/Core/Storage/RecoveryConnectionList.swift b/TablePro/Core/Storage/RecoveryConnectionList.swift index 17b1850729..b74af9cc1e 100644 --- a/TablePro/Core/Storage/RecoveryConnectionList.swift +++ b/TablePro/Core/Storage/RecoveryConnectionList.swift @@ -19,4 +19,10 @@ enum RecoveryConnectionList { .map(\.connectionId) .filter { seen.insert($0).inserted } } + + /// A session a client launched that never showed a connection says nothing about the person's + /// last one, so quitting it leaves the recovery list as it found it. + static func shouldSaveOnQuit(_ connectionIds: [UUID], origin: AppSessionOrigin) -> Bool { + !connectionIds.isEmpty || origin == .user + } } diff --git a/TablePro/Views/Settings/Sections/MCPSection.swift b/TablePro/Views/Settings/Sections/MCPSection.swift index f7d637d0ce..f7bfdf155d 100644 --- a/TablePro/Views/Settings/Sections/MCPSection.swift +++ b/TablePro/Views/Settings/Sections/MCPSection.swift @@ -35,19 +35,12 @@ struct MCPSection: View { } } - /// The server starts on demand whether or not the toggle is on, so the toggle cannot be what - /// decides whether a running one is reported. With it off this was the only place left that - /// could say a server was running, and it said nothing at all. - private var showsStatus: Bool { - settings.enabled || manager.state != .stopped - } - var body: some View { Section { Toggle(String(localized: "Enable MCP Server"), isOn: $settings.enabled) .accessibilityIdentifier("mcp-enable-server-toggle") - if showsStatus { + if settings.enabled { LabeledContent(String(localized: "Status")) { MCPStatusIndicator() } diff --git a/TableProTests/Core/MCP/Lifecycle/MCPServerOffContractTests.swift b/TableProTests/Core/MCP/Lifecycle/MCPServerOffContractTests.swift new file mode 100644 index 0000000000..6f5a739acf --- /dev/null +++ b/TableProTests/Core/MCP/Lifecycle/MCPServerOffContractTests.swift @@ -0,0 +1,197 @@ +import Foundation +import Testing + +@testable import TablePro + +@MainActor +struct MCPServerOffContractTests { + private let directory: URL + + init() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("mcp-off-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + } + + private func makeManager() -> MCPServerManager { + MCPServerManager(handshake: MCPHandshakeFile(directory: directory), isEnabled: { false }) + } + + private var handshakeExists: Bool { + FileManager.default.fileExists(atPath: MCPHandshakeLocation.file(in: directory).path) + } + + private func readRefusal() throws -> MCPStartRefusal { + let data = try Data(contentsOf: MCPHandshakeLocation.refusalFile(in: directory)) + return try MCPHandshakeCoding.decoder().decode(MCPStartRefusal.self, from: data) + } + + private func expectNoListener(_ manager: MCPServerManager) { + #expect(manager.state == .stopped) + #expect(manager.tokenStore == nil) + #expect(!handshakeExists) + } + + @Test("A launch start is refused while MCP is off") + func launchStartIsRefused() async throws { + let manager = makeManager() + + await #expect(throws: MCPServerStartError.turnedOff) { + try await manager.start(port: 0) + } + + expectNoListener(manager) + #expect(try readRefusal().pid == ProcessInfo.processInfo.processIdentifier) + } + + @Test("A start-mcp request is refused while MCP is off") + func lazyStartIsRefused() async throws { + let manager = makeManager() + + await #expect(throws: MCPServerStartError.turnedOff) { + try await manager.lazyStart() + } + + expectNoListener(manager) + _ = try readRefusal() + } + + @Test("A restart from Settings is refused while MCP is off") + func scheduledRestartIsRefused() async throws { + let manager = makeManager() + + manager.scheduleRestart(port: 0) + await manager.stop() + + expectNoListener(manager) + _ = try readRefusal() + } + + @Test("Pairing is refused before the approval prompt while MCP is off") + func pairingIsRefused() async throws { + let manager = makeManager() + let service = MCPPairingService(serverManager: manager) + let redirect = try #require(URL(string: "http://127.0.0.1:7391/callback")) + let request = PairingRequest( + clientName: "Launcher", + challenge: PairingExchangeStore.sha256Base64Url( + of: String(repeating: "a", count: PairingPkceValidator.minimumVerifierLength) + ), + redirectURL: redirect, + requestedScopes: nil, + requestedConnectionIds: nil + ) + + await #expect(throws: MCPServerStartError.turnedOff) { + try await service.startPairing(request) + } + + expectNoListener(manager) + } + + @Test("The refusal message names Settings > MCP") + func refusalNamesTheSetting() { + #expect(MCPServerStartError.turnedOff.localizedDescription.contains("Settings > MCP")) + } +} + +struct MCPBridgeRefusalTests { + private let directory: URL + + init() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("mcp-bridge-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + } + + @Test("The bridge gives up at once when the app refuses its launch") + func bridgeFailsOnARefusal() async throws { + let directory = directory + let acquirer = MCPHandshakeAcquirer( + logger: SilentBridgeLogger(), + supportDirectory: directory, + pollTimeout: .seconds(30), + launchHostApp: { _ in try Self.writeRefusal(at: Date(), in: directory) } + ) + + let started = ContinuousClock().now + await #expect(throws: MCPHandshakeAcquisitionError.serverTurnedOff) { + _ = try await acquirer.acquire() + } + #expect(ContinuousClock().now - started < .seconds(5)) + #expect(MCPHandshakeAcquisitionError.serverTurnedOff.localizedDescription.contains("Settings > MCP")) + } + + @Test("A refusal from before the launch does not answer it") + func staleRefusalIsIgnored() async throws { + try Self.writeRefusal(at: Date().addingTimeInterval(-60), in: directory) + let acquirer = MCPHandshakeAcquirer( + logger: SilentBridgeLogger(), + supportDirectory: directory, + pollTimeout: .milliseconds(300), + launchHostApp: { _ in } + ) + + do { + _ = try await acquirer.acquire() + Issue.record("Expected the bridge to time out") + } catch let error as MCPHandshakeAcquisitionError { + guard case .timeout = error else { + Issue.record("Expected a timeout, got \(error)") + return + } + } + } + + @Test("A refusal answers only a launch requested before it, to the sub-second") + func refusalAnswersLaterLaunchesOnly() throws { + let launch = Date(timeIntervalSince1970: 1_000.9) + #expect(MCPStartRefusal(pid: 1, refusedAt: launch.addingTimeInterval(0.05)).answers(launchRequestedAt: launch)) + #expect(MCPStartRefusal(pid: 1, refusedAt: launch.addingTimeInterval(5)).answers(launchRequestedAt: launch)) + #expect(!MCPStartRefusal(pid: 1, refusedAt: launch.addingTimeInterval(-5)).answers(launchRequestedAt: launch)) + + let earlierInTheSameSecond = MCPStartRefusal(pid: 1, refusedAt: Date(timeIntervalSince1970: 1_000.1)) + let decoded = try MCPHandshakeCoding.decoder().decode( + MCPStartRefusal.self, + from: MCPHandshakeCoding.encoder().encode(earlierInTheSameSecond) + ) + #expect(!decoded.answers(launchRequestedAt: launch)) + } + + private static func writeRefusal(at date: Date, in directory: URL) throws { + let data = try MCPHandshakeCoding.encoder().encode(MCPStartRefusal(pid: 1, refusedAt: date)) + try data.write(to: MCPHandshakeLocation.refusalFile(in: directory)) + } +} + +private struct SilentBridgeLogger: MCPBridgeLogger { + func log(_ level: MCPBridgeLogLevel, _ message: String) {} +} + +@MainActor +struct MCPServerLifecycleQueueResultTests { + @Test("run hands back what its work returned, after the work queued before it") + func runReturnsTheValueInOrder() async { + let queue = MCPServerLifecycleQueue() + let log = OrderLog() + + queue.enqueue { + try? await Task.sleep(for: .milliseconds(20)) + log.entries.append("earlier") + } + let value = await queue.run { () async -> String in + log.entries.append("run") + return "result" + } + + #expect(value == "result") + #expect(log.entries == ["earlier", "run"]) + } +} + +@MainActor +private final class OrderLog { + var entries: [String] = [] +} diff --git a/TableProTests/Core/Storage/RecoveryConnectionListTests.swift b/TableProTests/Core/Storage/RecoveryConnectionListTests.swift index c2e3ac211d..8319dd2676 100644 --- a/TableProTests/Core/Storage/RecoveryConnectionListTests.swift +++ b/TableProTests/Core/Storage/RecoveryConnectionListTests.swift @@ -96,4 +96,11 @@ struct RecoveryConnectionListTests { func emptyCandidatesRestoreNothing() { #expect(RecoveryConnectionList.connectionIds(from: []).isEmpty) } + + @Test("Quitting a client-launched session with nothing open keeps the last session") + func machineSessionWithNothingOpenKeepsTheList() { + #expect(!RecoveryConnectionList.shouldSaveOnQuit([], origin: .machine)) + #expect(RecoveryConnectionList.shouldSaveOnQuit([UUID()], origin: .machine)) + #expect(RecoveryConnectionList.shouldSaveOnQuit([], origin: .user)) + } }