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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- iOS: running a query that returns a very large result no longer crashes the app. The query editor keeps the first rows it loads, stops before memory runs low, and tells you to add LIMIT to fetch more.
- iOS: Safe Mode "Confirm Writes" now prompts before saving a row edit or inserting a row, matching the query editor. Previously grid edits and inserts saved with no confirmation.
- Redshift: schema switching now works, along with the contains, starts with, and ends with filters and table search. All previously failed with a SQL syntax error. (#1439)
- MCP server: the first authenticated request no longer hangs after turning on Require Authentication. Turning the setting on now creates a default token if you have none, shows it once for you to copy, and waits for the server to be ready before the next request can run. The Token Name field also focuses on first click in the Generate Token sheet. (#1093)
- Double-clicking a CSV or TSV file when TablePro is closed now opens the file directly, instead of showing the welcome screen. (#1443)
- Opening a `.sql` file now names the tab after the file instead of showing "SQL Query". (#1220)

Expand Down
39 changes: 39 additions & 0 deletions TablePro/Core/MCP/Auth/MCPCompositeAuthenticator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

public actor MCPCompositeAuthenticator: MCPAuthenticator {
private let bearer: MCPBearerTokenAuthenticator
private let requireAuthentication: Bool

private static let anonymousLoopbackPrincipal = MCPPrincipal(
tokenFingerprint: "anonymous-loopback",
tokenId: nil,
scopes: [.toolsRead, .toolsWrite, .resourcesRead, .admin],
metadata: MCPPrincipalMetadata(
label: "Anonymous (loopback)",
issuedAt: .distantPast,
expiresAt: nil
)
)

public init(
bearer: MCPBearerTokenAuthenticator,
requireAuthentication: Bool
) {
self.bearer = bearer
self.requireAuthentication = requireAuthentication
}

public func authenticate(
authorizationHeader: String?,
clientAddress: MCPClientAddress
) async -> MCPAuthDecision {
if !requireAuthentication, case .loopback = clientAddress {
MCPAuditLogger.logAuthAllowedAnonymous(ip: "127.0.0.1")
return .allow(Self.anonymousLoopbackPrincipal)
}
return await bearer.authenticate(
authorizationHeader: authorizationHeader,
clientAddress: clientAddress
)
}
}
10 changes: 10 additions & 0 deletions TablePro/Core/MCP/MCPAuditLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ enum MCPAuditLogger {
)
}

static func logAuthAllowedAnonymous(ip: String) {
serverAuth.info("Auth allowed anonymously (loopback, requireAuthentication=false): ip=\(ip, privacy: .public)")
record(
category: .auth,
action: "auth.anonymousLoopback",
outcome: .success,
details: "ip=\(ip)"
)
}

static func logAuthFailure(reason: String, ip: String) {
serverAuth.warning("Auth failure: reason=\(reason, privacy: .public) ip=\(ip, privacy: .public)")
record(
Expand Down
33 changes: 32 additions & 1 deletion TablePro/Core/MCP/MCPServerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class MCPServerManager {
private var internalBridgeToken: String?
private var serverGeneration: Int = 0
private var revocationObserverId: UUID?
private var lifecycleTask: Task<Void, Never>?

var isRunning: Bool {
if case .running = state { return true } else { return false }
Expand Down Expand Up @@ -97,10 +98,14 @@ final class MCPServerManager {
let newRateLimiter = MCPRateLimiter()
rateLimiter = newRateLimiter

let authenticator = MCPBearerTokenAuthenticator(
let bearerAuthenticator = MCPBearerTokenAuthenticator(
tokenStore: newTokenStore,
rateLimiter: newRateLimiter
)
let authenticator = MCPCompositeAuthenticator(
bearer: bearerAuthenticator,
requireAuthentication: settings.requireAuthentication
)

let newTransport = MCPHttpServerTransport(
configuration: configuration,
Expand Down Expand Up @@ -174,6 +179,32 @@ final class MCPServerManager {
await start(port: port)
}

func scheduleStart(port: UInt16) {
enqueueLifecycle { [weak self] in
await self?.start(port: port)
}
}

func scheduleStop() {
enqueueLifecycle { [weak self] in
await self?.stop()
}
}

func scheduleRestart(port: UInt16) {
enqueueLifecycle { [weak self] in
await self?.restart(port: port)
}
}

private func enqueueLifecycle(_ work: @escaping @MainActor () async -> Void) {
let previousTask = lifecycleTask
lifecycleTask = Task { @MainActor in
await previousTask?.value
await work()
}
}

func lazyStart() async {
if case .running = state { return }
if case .starting = state { return }
Expand Down
63 changes: 50 additions & 13 deletions TablePro/Core/MCP/Transport/MCPHttpServerTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public actor MCPHttpServerTransport {
private var sseWriters: [UUID: MCPSseWriter] = [:]
private var sseConnectionsBySession: [MCPSessionId: UUID] = [:]
private var sessionEventsTask: Task<Void, Never>?
private var readyContinuation: CheckedContinuation<Void, Error>?
private var readyTimeoutTask: Task<Void, Never>?
private static let readyTimeout: Duration = .seconds(5)

nonisolated public let exchanges: AsyncStream<MCPInboundExchange>
nonisolated private let exchangesContinuation: AsyncStream<MCPInboundExchange>.Continuation
Expand Down Expand Up @@ -71,33 +74,64 @@ public actor MCPHttpServerTransport {
emitState(.starting)

let parameters: NWParameters = makeParameters()

let newListener: NWListener
do {
let newListener = try NWListener(using: parameters)
listener = newListener
newListener = try NWListener(using: parameters)
} catch {
emitState(.failed(reason: error.localizedDescription))
throw MCPHttpServerError.bindFailed(reason: error.localizedDescription)
}
listener = newListener

newListener.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { await self.handleListenerState(state) }
}
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.newConnectionHandler = { [weak self] connection in
guard let self else { return }
Task { await self.handleNewConnection(connection) }
}

newListener.start(queue: .global(qos: .userInitiated))
startSessionEventListener()
do {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
readyContinuation = continuation
readyTimeoutTask = Task { [weak self] in
try? await Task.sleep(for: Self.readyTimeout)
await self?.handleReadyTimeout()
}
newListener.start(queue: .global(qos: .userInitiated))
startSessionEventListener()
}
} catch {
readyTimeoutTask?.cancel()
readyTimeoutTask = nil
emitState(.failed(reason: error.localizedDescription))
newListener.cancel()
listener = nil
throw MCPHttpServerError.bindFailed(reason: error.localizedDescription)
}
}

private func resumeReady(with result: Result<Void, Error>) {
guard let continuation = readyContinuation else { return }
readyContinuation = nil
readyTimeoutTask?.cancel()
readyTimeoutTask = nil
continuation.resume(with: result)
}

private func handleReadyTimeout() {
guard readyContinuation != nil else { return }
Self.logger.error("MCP HTTP listener did not reach .ready within timeout")
resumeReady(with: .failure(MCPHttpServerError.bindFailed(reason: "listener startup timed out")))
}

public func stop() async {
Self.logger.info("Stopping MCP HTTP server")

resumeReady(with: .failure(MCPHttpServerError.bindFailed(reason: "stop() called before listener ready")))

sessionEventsTask?.cancel()
sessionEventsTask = nil

Expand Down Expand Up @@ -181,15 +215,18 @@ public actor MCPHttpServerTransport {
let port = listener?.port?.rawValue ?? configuration.port
Self.logger.info("MCP HTTP server listening on port \(port, privacy: .public)")
emitState(.running(port: port))
resumeReady(with: .success(()))

case .failed(let error):
Self.logger.error("MCP HTTP listener failed: \(error.localizedDescription, privacy: .public)")
emitState(.failed(reason: error.localizedDescription))
listener?.cancel()
listener = nil
resumeReady(with: .failure(error))

case .cancelled:
Self.logger.debug("MCP HTTP listener cancelled")
resumeReady(with: .failure(MCPHttpServerError.bindFailed(reason: "listener cancelled before ready")))

default:
break
Expand Down
34 changes: 27 additions & 7 deletions TablePro/Core/Storage/AppSettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,18 +146,38 @@ final class AppSettingsManager {
let remoteChanged = mcp.allowRemoteConnections != oldValue.allowRemoteConnections
let authChanged = mcp.requireAuthentication != oldValue.requireAuthentication
if enabledChanged || portChanged || remoteChanged || authChanged {
let settings = mcp
Task { [mcpServerManager] in
if settings.enabled {
await mcpServerManager.restart(port: UInt16(clamping: settings.port))
} else {
await mcpServerManager.stop()
}
if mcp.enabled {
mcpServerManager.scheduleRestart(port: UInt16(clamping: mcp.port))
} else {
mcpServerManager.scheduleStop()
}
}
}
}

@MainActor
func setRequireAuthentication(_ value: Bool) async -> (token: MCPAuthToken, plaintext: String)? {
guard value, !mcp.requireAuthentication else {
mcp.requireAuthentication = value
return nil
}

let tokenStore = mcpServerManager.tokenStore ?? MCPTokenStore()
if mcpServerManager.tokenStore == nil {
await tokenStore.loadFromDisk()
}
let existing = await tokenStore.list().filter { $0.name != MCPTokenStore.stdioBridgeTokenName }
guard existing.isEmpty else {
mcp.requireAuthentication = value
return nil
}

let defaultName = String(localized: "Default token")
let result = await tokenStore.generate(name: defaultName, permissions: .fullAccess)
mcp.requireAuthentication = value
return result
}

@ObservationIgnored private let storage: AppSettingsStorage
@ObservationIgnored private let themeEngine: ThemeEngine
@ObservationIgnored private let syncTracker: SyncChangeTracker
Expand Down
26 changes: 25 additions & 1 deletion TablePro/Views/Settings/Sections/MCPSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,36 @@ import SwiftUI
struct MCPSection: View {
@Binding var settings: MCPSettings
@State private var manager = MCPServerManager.shared
@State private var settingsManager = AppSettingsManager.shared
@State private var tokenList: [MCPAuthToken] = []
@State private var showSetupSheet = false
@State private var showCreateSheet = false
@State private var showRevealSheet = false
@State private var revealedToken: MCPAuthToken?
@State private var revealedPlaintext: String = ""
@State private var isAuthBootstrapping = false

private var requireAuthBinding: Binding<Bool> {
Binding(
get: { settings.requireAuthentication },
set: { applyRequireAuthentication($0) }
)
}

private func applyRequireAuthentication(_ newValue: Bool) {
guard !isAuthBootstrapping else { return }
isAuthBootstrapping = true
Task { @MainActor in
defer { isAuthBootstrapping = false }
let bootstrap = await settingsManager.setRequireAuthentication(newValue)
if let bootstrap {
revealedToken = bootstrap.token
revealedPlaintext = bootstrap.plaintext
showRevealSheet = true
}
await refreshTokens()
}
}

var body: some View {
Section(String(localized: "Integrations")) {
Expand Down Expand Up @@ -72,7 +96,7 @@ struct MCPSection: View {

private var authenticationSection: some View {
Section(String(localized: "Authentication")) {
Toggle(String(localized: "Require authentication"), isOn: $settings.requireAuthentication)
Toggle(String(localized: "Require authentication"), isOn: requireAuthBinding)

if settings.requireAuthentication {
MCPTokenListView(
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import SwiftUI

struct MCPTokenCreateSheet: View {
private enum Field: Hashable {
case name
}

@Environment(\.dismiss) private var dismiss
let onGenerate: (String, TokenPermissions, Set<UUID>?, Date?) -> Void

Expand All @@ -11,6 +15,7 @@ struct MCPTokenCreateSheet: View {
@State private var expirationOption: ExpirationOption = .never
@State private var customExpirationDate = Calendar.current.date(byAdding: .day, value: 30, to: .now) ?? .now
@State private var connections: [DatabaseConnection] = []
@FocusState private var focused: Field?

var body: some View {
VStack(spacing: 0) {
Expand All @@ -29,6 +34,7 @@ struct MCPTokenCreateSheet: View {
.padding()
}
.frame(minWidth: 480, minHeight: 520)
.defaultFocus($focused, .name)
.task {
connections = ConnectionStorage.shared.loadConnections()
}
Expand All @@ -37,6 +43,7 @@ struct MCPTokenCreateSheet: View {
private var nameSection: some View {
Section(String(localized: "Token Name")) {
TextField(String(localized: "e.g., Claude Code on VPS"), text: $tokenName)
.focused($focused, equals: .name)
}
}

Expand Down
Loading
Loading