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 @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- File > Server-Side Export… for Oracle, Snowflake and BigQuery. Run the unload statement in a query tab instead.
- Cursor AI provider. A saved Cursor provider is dropped; Cursor still reaches TablePro through the MCP server.
- Sign in with xAI. An xAI provider now needs an xAI API key.
- Require authentication switch in Settings > MCP. Every MCP request needs a token, including one from this Mac.

### Fixed

Expand Down
35 changes: 0 additions & 35 deletions TablePro/Core/MCP/Auth/MCPCompositeAuthenticator.swift

This file was deleted.

36 changes: 4 additions & 32 deletions TablePro/Core/MCP/Auth/MCPPrincipal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,15 @@ public struct MCPPrincipalMetadata: Sendable, Equatable {
}

public struct MCPPrincipal: Sendable, Equatable, Hashable {
public static let anonymousFingerprint = "anonymous-loopback"

public let tokenFingerprint: String
public let tokenId: UUID?
public let tokenId: UUID
public let scopes: Set<MCPScope>
public let connectionAccess: ConnectionAccess
public let metadata: MCPPrincipalMetadata

public init(
tokenFingerprint: String,
tokenId: UUID? = nil,
tokenId: UUID,
scopes: Set<MCPScope>,
connectionAccess: ConnectionAccess = .all,
metadata: MCPPrincipalMetadata
Expand All @@ -37,19 +35,7 @@ public struct MCPPrincipal: Sendable, Equatable, Hashable {
self.metadata = metadata
}

public static let anonymousLoopback = MCPPrincipal(
tokenFingerprint: anonymousFingerprint,
tokenId: nil,
scopes: MCPScope.readOnlySet,
connectionAccess: .all,
metadata: MCPPrincipalMetadata(
label: String(localized: "Anonymous (loopback)"),
issuedAt: .distantPast,
expiresAt: nil
)
)

public static let inAppAssistantTokenId = UUID(uuidString: "00000000-0000-0000-0000-00000000A11A")
public static let inAppAssistantTokenId = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xA1, 0x1A))

public static let inAppAssistant = MCPPrincipal(
tokenFingerprint: "in-app-assistant",
Expand All @@ -63,13 +49,8 @@ public struct MCPPrincipal: Sendable, Equatable, Hashable {
)
)

public var isAnonymous: Bool {
tokenId == nil
}

public var ledgerKey: String {
guard let tokenId else { return "anon:\(tokenFingerprint)" }
return tokenId.uuidString
tokenId.uuidString
}

public var auditLabel: String? {
Expand All @@ -89,15 +70,6 @@ public struct MCPPrincipal: Sendable, Equatable, Hashable {
guard missing.isEmpty else {
throw MCPProtocolError.insufficientScope(required: required, reason: reason)
}
try requireIssuedToken(for: required, reason: reason)
}

public func requireIssuedToken(for required: Set<MCPScope>, reason: String) throws {
guard isAnonymous else { return }
let privileged = required.filter(\.requiresIssuedToken)
guard privileged.isEmpty else {
throw MCPProtocolError.insufficientScope(required: privileged, reason: reason)
}
}

public static func == (lhs: MCPPrincipal, rhs: MCPPrincipal) -> Bool {
Expand Down
9 changes: 0 additions & 9 deletions TablePro/Core/MCP/Auth/MCPScope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,6 @@ public enum MCPScope: String, Sendable, Equatable, Hashable, CaseIterable {
case admin
case connectionsDisplay = "connections:display"

public var requiresIssuedToken: Bool {
switch self {
case .toolsRead, .resourcesRead:
return false
case .toolsWrite, .admin, .connectionsDisplay:
return true
}
}

public static let readOnlySet: Set<MCPScope> = [.toolsRead, .resourcesRead]
public static let readWriteSet: Set<MCPScope> = [.toolsRead, .toolsWrite, .resourcesRead]
public static let fullAccessSet: Set<MCPScope> = [.toolsRead, .toolsWrite, .resourcesRead, .admin]
Expand Down
6 changes: 3 additions & 3 deletions TablePro/Core/MCP/Elicitation/MCPRequestState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public enum MCPRequestState {
let envelope = Envelope(
version: 1,
principal: principal.tokenFingerprint,
tokenId: principal.tokenId?.uuidString,
tokenId: principal.tokenId.uuidString,
method: method,
digest: paramsDigest,
expiresAt: Int(expiresAt.timeIntervalSince1970.rounded()),
Expand Down Expand Up @@ -70,7 +70,7 @@ public enum MCPRequestState {
throw MCPRequestStateError.malformed
}
guard envelope.principal == principal.tokenFingerprint,
envelope.tokenId == principal.tokenId?.uuidString
envelope.tokenId == principal.tokenId.uuidString
else {
throw MCPRequestStateError.principalMismatch
}
Expand Down Expand Up @@ -103,7 +103,7 @@ private extension MCPRequestState {
struct Envelope: Codable {
let version: Int
let principal: String
let tokenId: String?
let tokenId: String
let method: String
let digest: String
let expiresAt: Int
Expand Down
142 changes: 21 additions & 121 deletions TablePro/Core/MCP/Identity/MCPApprovalLedger.swift
Original file line number Diff line number Diff line change
@@ -1,59 +1,28 @@
import Foundation

/// Remembers which connections a client may reach.
///
/// Two tiers, because they answer to different lifetimes. A durable grant is a decision the user
/// made and belongs to a `MCPGrantSubject`, so it survives the server teardown that any settings
/// edit triggers, and a relaunch. A session approval is what a caller with no durable identity gets
/// instead: an anonymous loopback caller cannot be told apart from the next one, so remembering its
/// answer past this session would approve a process the user never saw.
/// Remembers which connections a client may reach. A grant is a decision the user made and belongs
/// to a `MCPGrantSubject`, so it survives the server teardown that a settings edit triggers, and a
/// relaunch.
public actor MCPApprovalLedger {
public struct Key: Sendable, Equatable, Hashable {
public let principalKey: String
public let connectionId: UUID

public init(principalKey: String, connectionId: UUID) {
self.principalKey = principalKey
self.connectionId = connectionId
}
}

/// Long enough that a model retrying a refused call gives up before the user is asked twice,
/// short enough that a user who meant to allow it can simply ask the client to try again.
static let denialCoolOff: TimeInterval = 60

private let ttl: Duration
private let clock: any MCPClock
private let store: any MCPApprovalStoring
private var approvals: [Key: Date] = [:]
private var denials: [Key: Date] = [:]

public init(ttl: Duration = .seconds(1_800), clock: any MCPClock = MCPSystemClock()) {
self.init(ttl: ttl, clock: clock, store: MCPApprovalStore.shared)
public init(clock: any MCPClock = MCPSystemClock()) {
self.init(clock: clock, store: MCPApprovalStore.shared)
}

init(
ttl: Duration = .seconds(1_800),
clock: any MCPClock = MCPSystemClock(),
store: any MCPApprovalStoring
) {
self.ttl = ttl
init(clock: any MCPClock = MCPSystemClock(), store: any MCPApprovalStoring) {
self.clock = clock
self.store = store
}

public func isApproved(principal: MCPPrincipal, connectionId: UUID) async -> Bool {
if let subject = principal.grantSubject {
let granted = await store.grants().contains {
$0.subject == subject.storageKey && $0.connectionId == connectionId
}
if granted { return true }
}
let now = await clock.now()
prune(now: now)
let key = Key(principalKey: principal.ledgerKey, connectionId: connectionId)
guard let expiresAt = approvals[key] else { return false }
return expiresAt > now
let subject = principal.grantSubject.storageKey
return await store.grants().contains { $0.subject == subject && $0.connectionId == connectionId }
}

/// `remember` is the user's setting speaking. At Ask Every Time nothing is written, so the next
Expand All @@ -65,72 +34,40 @@ public actor MCPApprovalLedger {
remember: Bool = true
) async {
let now = await clock.now()
prune(now: now)
let key = Key(principalKey: principal.ledgerKey, connectionId: connectionId)
let subject = principal.grantSubject.storageKey
guard approved else {
approvals.removeValue(forKey: key)
if let subject = principal.grantSubject {
await store.revoke(subject: subject.storageKey, connectionId: connectionId)
}
await recordDenial(principal: principal, connectionId: connectionId, now: now)
return
}
guard remember else { return }
if let subject = principal.grantSubject {
await store.revoke(subject: subject, connectionId: connectionId)
await store.record(
MCPConnectionGrant(subject: subject.storageKey, connectionId: connectionId, grantedAt: now)
MCPConnectionDenial(
subject: subject,
connectionId: connectionId,
expiresAt: now.addingTimeInterval(Self.denialCoolOff)
)
)
return
}
approvals[key] = now.addingTimeInterval(Self.seconds(of: ttl))
guard remember else { return }
await store.record(MCPConnectionGrant(subject: subject, connectionId: connectionId, grantedAt: now))
}

/// How long a refusal stands before the client may raise the question with the user again.
///
/// A caller with no durable identity still gets one, in memory: without it an unauthenticated
/// local process that retries a refused call raises a fresh focus-stealing alert every time,
/// which is the whole reason the cool-off exists.
public func denialExpiry(principal: MCPPrincipal, connectionId: UUID) async -> Date? {
let now = await clock.now()
guard let subject = principal.grantSubject else {
let key = Key(principalKey: principal.ledgerKey, connectionId: connectionId)
denials = denials.filter { $0.value > now }
return denials[key]
}
return await store.denial(subject: subject.storageKey, connectionId: connectionId, now: now)
return await store.denial(subject: principal.grantSubject.storageKey, connectionId: connectionId, now: now)
}

/// Drops the approvals a revoked token was carrying. Rotating the bridge credential is not a
/// revocation: the bridge re-mints itself roughly every 45 minutes and deletes the token it
/// replaces, so treating that as the user withdrawing consent re-asks them all day.
func clear(subject: MCPGrantSubject?) async {
guard let subject else {
approvals = approvals.filter { !$0.key.principalKey.hasPrefix("anon:") }
return
}
func clear(subject: MCPGrantSubject) async {
await store.revokeAll(subject: subject.storageKey)
if case .token(let tokenId) = subject {
approvals = approvals.filter { $0.key.principalKey != tokenId.uuidString }
}
}

public func revoke(subject: String, connectionId: UUID) async {
await store.revoke(subject: subject, connectionId: connectionId)
approvals = approvals.filter {
!($0.key.principalKey == subject && $0.key.connectionId == connectionId)
}
}

/// Ends the session's own approvals. Durable grants are untouched, because a server restart is
/// not the user changing their mind.
public func clearSessionApprovals() async {
approvals.removeAll()
denials.removeAll()
}

public func forgetEveryGrant() async {
approvals.removeAll()
denials.removeAll()
await store.revokeEverything()
}

Expand All @@ -139,44 +76,7 @@ public actor MCPApprovalLedger {
}

public func approvedConnectionIds(principal: MCPPrincipal) async -> Set<UUID> {
let now = await clock.now()
prune(now: now)
let principalKey = principal.ledgerKey
var ids = Set(
approvals
.filter { $0.key.principalKey == principalKey && $0.value > now }
.map(\.key.connectionId)
)
if let subject = principal.grantSubject {
let durable = await store.grants()
.filter { $0.subject == subject.storageKey }
.map(\.connectionId)
ids.formUnion(durable)
}
return ids
}

private func recordDenial(principal: MCPPrincipal, connectionId: UUID, now: Date) async {
let expiresAt = now.addingTimeInterval(Self.denialCoolOff)
guard let subject = principal.grantSubject else {
denials[Key(principalKey: principal.ledgerKey, connectionId: connectionId)] = expiresAt
return
}
await store.record(
MCPConnectionDenial(
subject: subject.storageKey,
connectionId: connectionId,
expiresAt: expiresAt
)
)
}

private func prune(now: Date) {
approvals = approvals.filter { $0.value > now }
}

private static func seconds(of duration: Duration) -> TimeInterval {
let components = duration.components
return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1.0e18
let subject = principal.grantSubject.storageKey
return Set(await store.grants().filter { $0.subject == subject }.map(\.connectionId))
}
}
11 changes: 2 additions & 9 deletions TablePro/Core/MCP/Identity/MCPGrantSubject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,7 @@ enum MCPGrantSubject: Sendable, Equatable {
}

extension MCPPrincipal {
/// The subject a durable grant belongs to, or nil when this caller may not earn one.
///
/// An anonymous loopback caller collapses to a single shared identity with nothing to
/// distinguish one local process from the next, so remembering its answer would silently
/// approve every future unauthenticated caller. It keeps the session-only approval it has today.
var grantSubject: MCPGrantSubject? {
if metadata.isBridgeCredential { return .bridge }
guard let tokenId else { return nil }
return .token(tokenId)
var grantSubject: MCPGrantSubject {
metadata.isBridgeCredential ? .bridge : .token(tokenId)
}
}
4 changes: 2 additions & 2 deletions TablePro/Core/MCP/Legacy/MCPLegacySession.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import Foundation

public struct MCPLegacySessionOwner: Sendable, Hashable {
public let tokenId: UUID?
public let tokenId: UUID
public let tokenFingerprint: String

public init(tokenId: UUID?, tokenFingerprint: String) {
public init(tokenId: UUID, tokenFingerprint: String) {
self.tokenId = tokenId
self.tokenFingerprint = tokenFingerprint
}
Expand Down
Loading
Loading