Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions TablePro/CLI/BridgeMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand Down
44 changes: 36 additions & 8 deletions TablePro/CLI/Handshake.swift
Original file line number Diff line number Diff line change
@@ -1,39 +1,53 @@
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 {
case .launchFailed(let status):
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) {
Expand All @@ -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")
Expand All @@ -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)
}
Expand Down
85 changes: 65 additions & 20 deletions TablePro/Core/MCP/Lifecycle/MCPHandshakeFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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)")
}
}

Expand Down
11 changes: 9 additions & 2 deletions TablePro/Core/MCP/Lifecycle/MCPServerLifecycleQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value: Sendable>(_ 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 {
Expand Down
Loading
Loading