diff --git a/CHANGELOG.md b/CHANGELOG.md index 2339de5aa0..1dbd32de00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/MCP/Auth/MCPCompositeAuthenticator.swift b/TablePro/Core/MCP/Auth/MCPCompositeAuthenticator.swift deleted file mode 100644 index 2346895ec4..0000000000 --- a/TablePro/Core/MCP/Auth/MCPCompositeAuthenticator.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation -import os - -public actor MCPCompositeAuthenticator: MCPAuthenticator { - private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.Auth") - - private let bearer: MCPBearerTokenAuthenticator - private let requireAuthentication: Bool - - public init( - bearer: MCPBearerTokenAuthenticator, - requireAuthentication: Bool - ) { - self.bearer = bearer - self.requireAuthentication = requireAuthentication - } - - public func authenticate( - authorizationHeader: String?, - clientAddress: MCPClientAddress - ) async -> MCPAuthDecision { - let presentedCredential = authorizationHeader?.isEmpty == false - - guard !requireAuthentication, clientAddress.isLoopback, !presentedCredential else { - return await bearer.authenticate( - authorizationHeader: authorizationHeader, - clientAddress: clientAddress - ) - } - - Self.logger.info("Auth allowed anonymously on loopback with no credential presented") - MCPAuditLogger.logAuthAllowedAnonymous(ip: clientAddress.displayValue) - return .allow(.anonymousLoopback) - } -} diff --git a/TablePro/Core/MCP/Auth/MCPPrincipal.swift b/TablePro/Core/MCP/Auth/MCPPrincipal.swift index 5d44fb3cb2..19c316e8e9 100644 --- a/TablePro/Core/MCP/Auth/MCPPrincipal.swift +++ b/TablePro/Core/MCP/Auth/MCPPrincipal.swift @@ -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 public let connectionAccess: ConnectionAccess public let metadata: MCPPrincipalMetadata public init( tokenFingerprint: String, - tokenId: UUID? = nil, + tokenId: UUID, scopes: Set, connectionAccess: ConnectionAccess = .all, metadata: MCPPrincipalMetadata @@ -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", @@ -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? { @@ -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, 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 { diff --git a/TablePro/Core/MCP/Auth/MCPScope.swift b/TablePro/Core/MCP/Auth/MCPScope.swift index 8bd824e376..71f92bd921 100644 --- a/TablePro/Core/MCP/Auth/MCPScope.swift +++ b/TablePro/Core/MCP/Auth/MCPScope.swift @@ -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 = [.toolsRead, .resourcesRead] public static let readWriteSet: Set = [.toolsRead, .toolsWrite, .resourcesRead] public static let fullAccessSet: Set = [.toolsRead, .toolsWrite, .resourcesRead, .admin] diff --git a/TablePro/Core/MCP/Elicitation/MCPRequestState.swift b/TablePro/Core/MCP/Elicitation/MCPRequestState.swift index b726319805..797329b5fd 100644 --- a/TablePro/Core/MCP/Elicitation/MCPRequestState.swift +++ b/TablePro/Core/MCP/Elicitation/MCPRequestState.swift @@ -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()), @@ -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 } @@ -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 diff --git a/TablePro/Core/MCP/Identity/MCPApprovalLedger.swift b/TablePro/Core/MCP/Identity/MCPApprovalLedger.swift index a2f04aaa86..25d827abc8 100644 --- a/TablePro/Core/MCP/Identity/MCPApprovalLedger.swift +++ b/TablePro/Core/MCP/Identity/MCPApprovalLedger.swift @@ -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 @@ -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() } @@ -139,44 +76,7 @@ public actor MCPApprovalLedger { } public func approvedConnectionIds(principal: MCPPrincipal) async -> Set { - 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)) } } diff --git a/TablePro/Core/MCP/Identity/MCPGrantSubject.swift b/TablePro/Core/MCP/Identity/MCPGrantSubject.swift index 787c8ee0aa..de3652223f 100644 --- a/TablePro/Core/MCP/Identity/MCPGrantSubject.swift +++ b/TablePro/Core/MCP/Identity/MCPGrantSubject.swift @@ -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) } } diff --git a/TablePro/Core/MCP/Legacy/MCPLegacySession.swift b/TablePro/Core/MCP/Legacy/MCPLegacySession.swift index 26b01b28e6..4909394af7 100644 --- a/TablePro/Core/MCP/Legacy/MCPLegacySession.swift +++ b/TablePro/Core/MCP/Legacy/MCPLegacySession.swift @@ -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 } diff --git a/TablePro/Core/MCP/Lifecycle/MCPServerComposition.swift b/TablePro/Core/MCP/Lifecycle/MCPServerComposition.swift index a7cd3c1b84..3d57ca164a 100644 --- a/TablePro/Core/MCP/Lifecycle/MCPServerComposition.swift +++ b/TablePro/Core/MCP/Lifecycle/MCPServerComposition.swift @@ -4,7 +4,7 @@ import Foundation internal struct MCPServerComposition { internal let tokenStore: MCPTokenStore internal let rateLimiter: MCPRateLimiter - internal let authenticator: MCPCompositeAuthenticator + internal let authenticator: MCPBearerTokenAuthenticator internal let services: MCPToolServices internal let activityLedger: MCPClientActivityLedger internal let subscriptions: MCPSubscriptionRegistry @@ -17,16 +17,9 @@ internal struct MCPServerComposition { services.authPolicy } - internal static func build( - port: UInt16, - settings: MCPSettings, - tokenStore: MCPTokenStore - ) -> MCPServerComposition { + internal static func build(port: UInt16, tokenStore: MCPTokenStore) -> MCPServerComposition { let rateLimiter = MCPRateLimiter() - let authenticator = MCPCompositeAuthenticator( - bearer: MCPBearerTokenAuthenticator(tokenStore: tokenStore, rateLimiter: rateLimiter), - requireAuthentication: settings.requireAuthentication - ) + let authenticator = MCPBearerTokenAuthenticator(tokenStore: tokenStore, rateLimiter: rateLimiter) let transport = MCPHttpServerTransport( configuration: .loopback(port: port), authenticator: authenticator @@ -71,6 +64,5 @@ internal struct MCPServerComposition { await subscriptions.closeAll() await dispatcher.cancelAllInflight() await transport.stop() - await services.authPolicy.clearAllApprovals() } } diff --git a/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift b/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift index 6b21ce56ee..23da0ee894 100644 --- a/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift +++ b/TablePro/Core/MCP/Lifecycle/MCPServerManager.swift @@ -178,11 +178,10 @@ internal final class MCPServerManager: ObservableObject { await store.loadFromDisk() tokenStore = store - let settings = AppSettingsManager.shared.mcp var lastFailure = "" for candidate in MCPPortAllocator.candidates(preferred: Int(port)) { - let built = MCPServerComposition.build(port: candidate, settings: settings, tokenStore: store) + let built = MCPServerComposition.build(port: candidate, tokenStore: store) do { try await built.transport.start() } catch { diff --git a/TablePro/Core/MCP/MCPApprovalPresenter.swift b/TablePro/Core/MCP/MCPApprovalPresenter.swift index f3f4bc6529..bec6001068 100644 --- a/TablePro/Core/MCP/MCPApprovalPresenter.swift +++ b/TablePro/Core/MCP/MCPApprovalPresenter.swift @@ -4,7 +4,6 @@ import Foundation /// How long an Allow lasts, so the prompt can say so rather than guess. enum MCPApprovalMemory: Sendable, Equatable { case thisRequestOnly - case thisSession case untilRevoked } @@ -63,8 +62,6 @@ struct MCPApprovalAlertPresenter: MCPApprovalPresenting { switch memory { case .thisRequestOnly: return String(localized: "Allowing covers this request only.") - case .thisSession: - return String(localized: "Allowing is remembered until TablePro quits.") case .untilRevoked: return String(localized: "Allowing is remembered for this client until you revoke it.") } diff --git a/TablePro/Core/MCP/MCPAuditLogger.swift b/TablePro/Core/MCP/MCPAuditLogger.swift index 4abe203572..4842b2d684 100644 --- a/TablePro/Core/MCP/MCPAuditLogger.swift +++ b/TablePro/Core/MCP/MCPAuditLogger.swift @@ -49,16 +49,6 @@ 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( diff --git a/TablePro/Core/MCP/MCPAuthPolicy.swift b/TablePro/Core/MCP/MCPAuthPolicy.swift index c59d8a2fbb..86866065d0 100644 --- a/TablePro/Core/MCP/MCPAuthPolicy.swift +++ b/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -239,7 +239,7 @@ public actor MCPAuthPolicy { connectionName: snapshot.name, databaseType: snapshot.databaseType, clientLabel: principal.auditLabel, - memory: Self.memory(of: approval, for: principal) + memory: approval.remembersAnswer ? .untilRevoked : .thisRequestOnly ) ) } @@ -252,14 +252,9 @@ public actor MCPAuthPolicy { /// 45 minutes and deletes the one it replaces, which reaches here as a revocation; treating that /// as the user withdrawing consent is what made the prompt come back all day, so the caller /// passes the token's name and the bridge's own rotation is skipped. - func clearApprovals(tokenId: UUID?, wasBridgeCredential: Bool = false) async { + func clearApprovals(tokenId: UUID, wasBridgeCredential: Bool = false) async { guard !wasBridgeCredential else { return } - await approvalLedger.clear(subject: tokenId.map { .token($0) }) - } - - /// Ends this session's approvals. A grant the user chose to remember is not one of them. - func clearAllApprovals() async { - await approvalLedger.clearSessionApprovals() + await approvalLedger.clear(subject: .token(tokenId)) } func forgetEveryGrant() async { @@ -345,7 +340,7 @@ public actor MCPAuthPolicy { _ capabilities: CallerCapabilities, for principal: MCPPrincipal ) -> CallerCapabilities { - guard !principal.has(.admin) || principal.isAnonymous else { return capabilities } + guard !principal.has(.admin) else { return capabilities } var effective = capabilities effective.remove(.confirmationPreCleared) effective.remove(.preCleared) @@ -392,19 +387,9 @@ public actor MCPAuthPolicy { ) } - /// A caller with no durable identity keeps its answer only for this session, so the prompt says - /// that rather than promising a grant the ledger will not file. - private static func memory( - of approval: MCPConnectionApproval, - for principal: MCPPrincipal - ) -> MCPApprovalMemory { - guard approval.remembersAnswer else { return .thisRequestOnly } - return principal.grantSubject == nil ? .thisSession : .untilRevoked - } - private static func adminScopeDenial(principal: MCPPrincipal, tool: MCPToolName) -> AuthDecision? { guard MCPToolName.requiresAdminScope.contains(tool) else { return nil } - guard !principal.has(.admin) || principal.isAnonymous else { return nil } + guard !principal.has(.admin) else { return nil } return .deniedInsufficientScope( required: [.admin], reason: "Tool '\(tool)' requires an issued token carrying the admin scope" @@ -421,7 +406,7 @@ public actor MCPAuthPolicy { guard QueryClassifier.isWriteQuery(sql, databaseType: DatabaseType(rawValue: databaseType)) else { return nil } - guard !principal.has(.toolsWrite) || principal.isAnonymous else { return nil } + guard !principal.has(.toolsWrite) else { return nil } return .deniedInsufficientScope( required: [.toolsWrite], reason: "Writing requires an issued token carrying the tools:write scope" diff --git a/TablePro/Core/MCP/Protocol/Extensions/MCPProtocolDispatcher+Requests.swift b/TablePro/Core/MCP/Protocol/Extensions/MCPProtocolDispatcher+Requests.swift index 48e193bedf..6694ebfaa8 100644 --- a/TablePro/Core/MCP/Protocol/Extensions/MCPProtocolDispatcher+Requests.swift +++ b/TablePro/Core/MCP/Protocol/Extensions/MCPProtocolDispatcher+Requests.swift @@ -58,15 +58,13 @@ extension MCPProtocolDispatcher { private func admitted( method: String, principal: MCPPrincipal, - address: MCPClientAddress, operation: () async -> MCPHandlerOutcome ) async -> MCPHandlerOutcome { guard let rateLimiter, !Self.deadlineExemptMethods.contains(method) else { return await operation() } - let subject: MCPRateLimitSubject = principal.tokenId.map { .token($0) } ?? .address(address) do { - return try await rateLimiter.withRequestSlot(subject: subject) { + return try await rateLimiter.withRequestSlot(subject: .token(principal.tokenId)) { await operation() } } catch let error as MCPProtocolError { @@ -159,11 +157,7 @@ extension MCPProtocolDispatcher { cancellation: cancellation ) } - let outcome = await admitted( - method: request.method, - principal: principal, - address: exchange.context.clientAddress - ) { + let outcome = await admitted(method: request.method, principal: principal) { await self.execute(handler, request: request, context: context, cancellation: cancellation) } if let legacySessionId { diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 42fd136b37..9ac1f238de 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -153,8 +153,7 @@ final class AppSettingsManager: ObservableObject { storage.saveMCP(validated) let enabledChanged = mcp.enabled != oldValue.enabled let portChanged = mcp.port != oldValue.port - let authChanged = mcp.requireAuthentication != oldValue.requireAuthentication - if enabledChanged || portChanged || authChanged { + if enabledChanged || portChanged { if mcp.enabled { mcpServerManager.scheduleRestart(port: UInt16(clamping: mcp.port)) } else { @@ -164,36 +163,6 @@ final class AppSettingsManager: ObservableObject { } } - @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.isBridgeCredential } - guard existing.isEmpty else { - mcp.requireAuthentication = value - return nil - } - - let defaultName = String(localized: "Default token") - let result = try? await tokenStore.generate( - name: defaultName, - permissions: .readWrite, - connectionAccess: .all, - expiresAt: nil, - isBridgeCredential: false, - extraScopes: [] - ) - mcp.requireAuthentication = value - return result - } - private let storage: AppSettingsStorage private let themeEngine: ThemeEngine private let syncTracker: SyncChangeTracker diff --git a/TablePro/Core/Storage/MCPApprovalStore.swift b/TablePro/Core/Storage/MCPApprovalStore.swift index 4054120c9c..50115c1a90 100644 --- a/TablePro/Core/Storage/MCPApprovalStore.swift +++ b/TablePro/Core/Storage/MCPApprovalStore.swift @@ -34,9 +34,8 @@ protocol MCPApprovalStoring: Sendable { /// Durable, revocable MCP connection grants, device-local. /// /// A grant is a security decision the user made, so it outlives the session that earned it: the -/// server is torn down and rebuilt whenever the port or the authentication toggle changes, and the -/// app is relaunched far more often than that. It is never synced, for the same reason MCP tokens -/// are not. +/// server is torn down and rebuilt whenever the port changes, and the app is relaunched far more +/// often than that. It is never synced, for the same reason MCP tokens are not. actor MCPApprovalStore: MCPApprovalStoring { static let shared = MCPApprovalStore() diff --git a/TablePro/Models/Settings/MCPSettings.swift b/TablePro/Models/Settings/MCPSettings.swift index 6fbce54636..a6d7eca727 100644 --- a/TablePro/Models/Settings/MCPSettings.swift +++ b/TablePro/Models/Settings/MCPSettings.swift @@ -7,7 +7,6 @@ struct MCPSettings: Codable, Equatable { var maxRowLimit: Int var queryTimeoutSeconds: Int var logQueriesInHistory: Bool - var requireAuthentication: Bool var connectionApproval: MCPConnectionApproval var allowsHiddenConnectionListing: Bool @@ -18,7 +17,6 @@ struct MCPSettings: Codable, Equatable { maxRowLimit: 10_000, queryTimeoutSeconds: 30, logQueriesInHistory: true, - requireAuthentication: true, connectionApproval: .oncePerConnection, allowsHiddenConnectionListing: false ) @@ -30,7 +28,6 @@ struct MCPSettings: Codable, Equatable { maxRowLimit: Int = 10_000, queryTimeoutSeconds: Int = 30, logQueriesInHistory: Bool = true, - requireAuthentication: Bool = true, connectionApproval: MCPConnectionApproval = .oncePerConnection, allowsHiddenConnectionListing: Bool = false ) { @@ -40,7 +37,6 @@ struct MCPSettings: Codable, Equatable { self.maxRowLimit = maxRowLimit self.queryTimeoutSeconds = queryTimeoutSeconds self.logQueriesInHistory = logQueriesInHistory - self.requireAuthentication = requireAuthentication self.connectionApproval = connectionApproval self.allowsHiddenConnectionListing = allowsHiddenConnectionListing } @@ -55,7 +51,6 @@ struct MCPSettings: Codable, Equatable { maxRowLimit = container.decodeSetting(.maxRowLimit, default: fallback.maxRowLimit) queryTimeoutSeconds = container.decodeSetting(.queryTimeoutSeconds, default: fallback.queryTimeoutSeconds) logQueriesInHistory = container.decodeSetting(.logQueriesInHistory, default: fallback.logQueriesInHistory) - requireAuthentication = container.decodeSetting(.requireAuthentication, default: fallback.requireAuthentication) connectionApproval = container.decodeSetting(.connectionApproval, default: fallback.connectionApproval) allowsHiddenConnectionListing = container.decodeSetting( .allowsHiddenConnectionListing, default: fallback.allowsHiddenConnectionListing diff --git a/TablePro/Views/Settings/Sections/MCPSection.swift b/TablePro/Views/Settings/Sections/MCPSection.swift index f7bfdf155d..99a498c560 100644 --- a/TablePro/Views/Settings/Sections/MCPSection.swift +++ b/TablePro/Views/Settings/Sections/MCPSection.swift @@ -4,36 +4,12 @@ import SwiftUI struct MCPSection: View { @Binding var settings: MCPSettings @ObservedObject private var manager = MCPServerManager.shared - @ObservedObject 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 { - 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 { @@ -126,16 +102,12 @@ struct MCPSection: View { private var authenticationSection: some View { Section(String(localized: "Authentication")) { - Toggle(String(localized: "Require authentication"), isOn: requireAuthBinding) - - if settings.requireAuthentication { - MCPTokenListView( - tokens: tokenList, - onGenerate: { showCreateSheet = true }, - onRevoke: { id in Task { await manager.tokenStore?.revoke(tokenId: id); await refreshTokens() } }, - onDelete: { id in Task { await manager.tokenStore?.delete(tokenId: id); await refreshTokens() } } - ) - } + MCPTokenListView( + tokens: tokenList, + onGenerate: { showCreateSheet = true }, + onRevoke: { id in Task { await manager.tokenStore?.revoke(tokenId: id); await refreshTokens() } }, + onDelete: { id in Task { await manager.tokenStore?.delete(tokenId: id); await refreshTokens() } } + ) } .task { await refreshTokens() } .sheet(isPresented: $showCreateSheet) { diff --git a/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift b/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift index 42bcd6e91f..0b5eac9d00 100644 --- a/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift +++ b/TableProTests/Core/MCP/Auth/MCPBearerTokenAuthenticatorTests.swift @@ -144,7 +144,6 @@ struct MCPBearerTokenAuthenticatorTests { #expect(principal.scopes == MCPScope.readWriteSet) #expect(principal.connectionAccess.allows(allowed)) #expect(principal.connectionAccess.allows(UUID()) == false) - #expect(principal.isAnonymous == false) } @Test("The principal fingerprint is a digest, never the presented token") @@ -481,4 +480,56 @@ struct MCPBearerTokenAuthenticatorTests { #expect(MCPBearerTokenAuthenticator.parseBearerToken("Bearer ") == nil) #expect(MCPBearerTokenAuthenticator.parseBearerToken(" Bearer tp_abc ") == "tp_abc") } + + @Test("No caller gets in without a token, loopback included") + func everyAddressNeedsAToken() async throws { + let (authenticator, _) = makeAuthenticator(store: FakeMCPTokenStore()) + + for address in [MCPClientAddress.loopback, .remote("192.168.1.5")] { + let decision = await authenticator.authenticate(authorizationHeader: nil, clientAddress: address) + let reason = try #require(denial(decision)) + #expect(reason.kind == .unauthenticated) + #expect(reason.httpStatus == 401) + } + } + + @Test("A valid token is accepted from a remote address") + func validTokenFromARemoteAddress() async { + let store = FakeMCPTokenStore() + await store.register("tp_remote", validated: makeValidated(label: "Remote Token")) + let (authenticator, _) = makeAuthenticator(store: store) + + let decision = await authenticator.authenticate( + authorizationHeader: "Bearer tp_remote", + clientAddress: .remote("192.168.1.5") + ) + + guard case .allow(let principal) = decision else { + Issue.record("Expected allow, got \(decision)") + return + } + #expect(principal.metadata.label == "Remote Token") + } + + @Test("A scope refusal names the missing scope in the WWW-Authenticate challenge") + func scopeRefusalCarriesAChallenge() throws { + let readOnly = MCPPrincipal( + tokenFingerprint: "fp", + tokenId: UUID(), + scopes: MCPScope.readOnlySet, + metadata: MCPPrincipalMetadata(label: "read only", issuedAt: .distantPast, expiresAt: nil) + ) + + do { + try readOnly.requireScopes([.toolsWrite], reason: "writing requires the write scope") + Issue.record("Expected the write scope to be refused") + } catch let error as MCPProtocolError { + #expect(error.httpStatus.code == 403) + let header = try #require(error.extraHeaders.first(where: { $0.0 == "WWW-Authenticate" })?.1) + #expect(header.hasPrefix("Bearer ")) + #expect(header.contains("realm=\"TablePro\"")) + #expect(header.contains("error=\"insufficient_scope\"")) + #expect(header.contains("scope=\"tools:write\"")) + } + } } diff --git a/TableProTests/Core/MCP/Auth/MCPCompositeAuthenticatorTests.swift b/TableProTests/Core/MCP/Auth/MCPCompositeAuthenticatorTests.swift deleted file mode 100644 index 2a72a3a3df..0000000000 --- a/TableProTests/Core/MCP/Auth/MCPCompositeAuthenticatorTests.swift +++ /dev/null @@ -1,252 +0,0 @@ -// -// MCPCompositeAuthenticatorTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -struct MCPCompositeAuthenticatorTests { - private func makeValidated( - label: String = "test", - scopes: Set = MCPScope.readWriteSet, - connectionAccess: ConnectionAccess = .all - ) -> MCPValidatedToken { - MCPValidatedToken( - tokenId: UUID(), - label: label, - scopes: scopes, - connectionAccess: connectionAccess, - issuedAt: Date(timeIntervalSince1970: 1_000_000), - expiresAt: nil - ) - } - - private func makeComposite( - _ store: FakeMCPTokenStore, - requireAuthentication: Bool - ) -> MCPCompositeAuthenticator { - let clock = MCPTestClock() - let bearer = MCPBearerTokenAuthenticator( - tokenStore: store, - rateLimiter: MCPRateLimiter(clock: clock), - clock: clock - ) - return MCPCompositeAuthenticator(bearer: bearer, requireAuthentication: requireAuthentication) - } - - private func denial(_ decision: MCPAuthDecision) -> MCPAuthDenialReason? { - guard case .deny(let reason) = decision else { return nil } - return reason - } - - @Test("Loopback with no credential and no requirement yields the anonymous principal") - func anonymousOnLoopback() async throws { - let composite = makeComposite(FakeMCPTokenStore(), requireAuthentication: false) - - let decision = await composite.authenticate(authorizationHeader: nil, clientAddress: .loopback) - - guard case .allow(let principal) = decision else { - Issue.record("Expected allow, got \(decision)") - return - } - #expect(principal == .anonymousLoopback) - #expect(principal.tokenId == nil) - #expect(principal.isAnonymous) - #expect(principal.tokenFingerprint == MCPPrincipal.anonymousFingerprint) - } - - @Test("The anonymous principal is read-only and carries no admin scope") - func anonymousPrincipalIsReadOnly() { - let anonymous = MCPPrincipal.anonymousLoopback - - #expect(anonymous.scopes == MCPScope.readOnlySet) - #expect(anonymous.has(.toolsRead)) - #expect(anonymous.has(.resourcesRead)) - #expect(anonymous.has(.toolsWrite) == false) - #expect(anonymous.has(.admin) == false) - } - - @Test("A write or admin scope is refused for an anonymous principal even when it claims one") - func anonymousCannotHoldPrivilegedScopes() throws { - let overreaching = MCPPrincipal( - tokenFingerprint: MCPPrincipal.anonymousFingerprint, - tokenId: nil, - scopes: Set(MCPScope.allCases), - connectionAccess: .all, - metadata: MCPPrincipalMetadata(label: "Anonymous", issuedAt: .distantPast, expiresAt: nil) - ) - - #expect(throws: MCPProtocolError.self) { - try overreaching.requireScopes([.toolsWrite], reason: "write") - } - #expect(throws: MCPProtocolError.self) { - try overreaching.requireScopes([.admin], reason: "admin") - } - try overreaching.requireScopes(MCPScope.readOnlySet, reason: "read") - } - - @Test("A scope refusal names the missing scope in the WWW-Authenticate challenge") - func scopeRefusalCarriesAChallenge() throws { - let anonymous = MCPPrincipal.anonymousLoopback - - do { - try anonymous.requireScopes([.toolsWrite], reason: "writing requires an issued token") - Issue.record("Expected the write scope to be refused") - } catch let error as MCPProtocolError { - #expect(error.httpStatus.code == 403) - let header = try #require(error.extraHeaders.first(where: { $0.0 == "WWW-Authenticate" })?.1) - #expect(header.hasPrefix("Bearer ")) - #expect(header.contains("realm=\"TablePro\"")) - #expect(header.contains("error=\"insufficient_scope\"")) - #expect(header.contains("scope=\"tools:write\"")) - } - } - - @Test("An unknown token on loopback is refused rather than downgraded to anonymous") - func unknownTokenIsNotDowngraded() async throws { - let composite = makeComposite(FakeMCPTokenStore(), requireAuthentication: false) - - let decision = await composite.authenticate( - authorizationHeader: "Bearer tp_unknown", - clientAddress: .loopback - ) - - let reason = try #require(denial(decision)) - #expect(reason.kind == .tokenInvalid) - } - - @Test("A revoked token gets no access even when authentication is not required") - func revokedTokenIsRefusedWithAuthenticationDisabled() async throws { - let store = FakeMCPTokenStore() - let plaintext = "tp_revoked" - await store.register(plaintext, validated: makeValidated()) - await store.markRevoked(plaintext) - let composite = makeComposite(store, requireAuthentication: false) - - let decision = await composite.authenticate( - authorizationHeader: "Bearer \(plaintext)", - clientAddress: .loopback - ) - - let reason = try #require(denial(decision)) - #expect(reason.kind == .tokenInvalid) - #expect(reason.logMessage == "token_revoked") - } - - @Test("An expired token gets no access even when authentication is not required") - func expiredTokenIsRefusedWithAuthenticationDisabled() async throws { - let store = FakeMCPTokenStore() - let plaintext = "tp_expired" - await store.register(plaintext, validated: makeValidated()) - await store.markExpired(plaintext) - let composite = makeComposite(store, requireAuthentication: false) - - let decision = await composite.authenticate( - authorizationHeader: "Bearer \(plaintext)", - clientAddress: .loopback - ) - - let reason = try #require(denial(decision)) - #expect(reason.kind == .tokenExpired) - } - - @Test("A valid token presented on loopback wins over the anonymous path") - func presentedTokenTakesPrecedenceOverAnonymous() async { - let store = FakeMCPTokenStore() - let plaintext = "tp_real" - await store.register(plaintext, validated: makeValidated(label: "Token A")) - let composite = makeComposite(store, requireAuthentication: false) - - let decision = await composite.authenticate( - authorizationHeader: "Bearer \(plaintext)", - clientAddress: .loopback - ) - - guard case .allow(let principal) = decision else { - Issue.record("Expected allow, got \(decision)") - return - } - #expect(principal.metadata.label == "Token A") - #expect(principal.tokenId != nil) - #expect(principal.isAnonymous == false) - } - - @Test("A remote client is never anonymous, whatever the requirement says") - func remoteClientIsNeverAnonymous() async throws { - let composite = makeComposite(FakeMCPTokenStore(), requireAuthentication: false) - - let decision = await composite.authenticate( - authorizationHeader: nil, - clientAddress: .remote("192.168.1.5") - ) - - let reason = try #require(denial(decision)) - #expect(reason.kind == .unauthenticated) - } - - @Test("Requiring authentication removes the anonymous path on loopback") - func requiringAuthenticationDeniesLoopbackWithoutCredential() async throws { - let composite = makeComposite(FakeMCPTokenStore(), requireAuthentication: true) - - let decision = await composite.authenticate(authorizationHeader: nil, clientAddress: .loopback) - - let reason = try #require(denial(decision)) - #expect(reason.kind == .unauthenticated) - #expect(reason.httpStatus == 401) - } - - @Test("Probing without a credential never locks out the client that then sends its token") - func probingWithoutCredentialDoesNotLockOut() async { - let store = FakeMCPTokenStore() - let plaintext = "tp_client" - await store.register(plaintext, validated: makeValidated(label: "Client")) - let composite = makeComposite(store, requireAuthentication: true) - - for _ in 0..<10 { - let probe = await composite.authenticate(authorizationHeader: nil, clientAddress: .loopback) - #expect(denial(probe)?.httpStatus == 401) - } - - let decision = await composite.authenticate( - authorizationHeader: "Bearer \(plaintext)", - clientAddress: .loopback - ) - guard case .allow(let principal) = decision else { - Issue.record("Expected allow, got \(decision)") - return - } - #expect(principal.metadata.label == "Client") - } - - @Test("Requiring authentication accepts a valid token from a remote client") - func remoteWithValidTokenIsAllowed() async { - let store = FakeMCPTokenStore() - let plaintext = "tp_remote" - await store.register(plaintext, validated: makeValidated(label: "Remote Token")) - let composite = makeComposite(store, requireAuthentication: true) - - let decision = await composite.authenticate( - authorizationHeader: "Bearer \(plaintext)", - clientAddress: .remote("192.168.1.5") - ) - - guard case .allow(let principal) = decision else { - Issue.record("Expected allow, got \(decision)") - return - } - #expect(principal.metadata.label == "Remote Token") - } - - @Test("The in-app assistant principal is an issued identity, not an anonymous one") - func inAppAssistantIsIssued() { - let assistant = MCPPrincipal.inAppAssistant - - #expect(assistant.isAnonymous == false) - #expect(assistant.tokenId == MCPPrincipal.inAppAssistantTokenId) - #expect(assistant.has(.admin)) - #expect(assistant.ledgerKey != MCPPrincipal.anonymousLoopback.ledgerKey) - } -} diff --git a/TableProTests/Core/MCP/Elicitation/MCPRequestStateTests.swift b/TableProTests/Core/MCP/Elicitation/MCPRequestStateTests.swift index 9d6f9fefcb..a241ecceb3 100644 --- a/TableProTests/Core/MCP/Elicitation/MCPRequestStateTests.swift +++ b/TableProTests/Core/MCP/Elicitation/MCPRequestStateTests.swift @@ -8,7 +8,7 @@ import Foundation import Testing struct MCPRequestStateTests { - private func principal(fingerprint: String = "fp-1", tokenId: UUID? = nil) -> MCPPrincipal { + private func principal(fingerprint: String = "fp-1", tokenId: UUID = MCPRequestStateTestIds.token) -> MCPPrincipal { MCPPrincipal( tokenFingerprint: fingerprint, tokenId: tokenId, @@ -147,7 +147,7 @@ struct MCPRequestStateTests { struct MCPRequestStateReplayTests { private let now = Date(timeIntervalSince1970: 1_700_000_000) - private func principal(fingerprint: String = "fp-1", tokenId: UUID? = nil) -> MCPPrincipal { + private func principal(fingerprint: String = "fp-1", tokenId: UUID = MCPRequestStateTestIds.token) -> MCPPrincipal { MCPPrincipal( tokenFingerprint: fingerprint, tokenId: tokenId, @@ -237,3 +237,7 @@ struct MCPRequestStateReplayTests { #expect(MCPRequestState.digest(ofParams: .string("not an object")) == MCPRequestState.digest(ofParams: nil)) } } + +private enum MCPRequestStateTestIds { + static let token = UUID(uuid: (0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22)) +} diff --git a/TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift b/TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift index 27418f6e2d..bf16814fd7 100644 --- a/TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift +++ b/TableProTests/Core/MCP/Helpers/MCPProtocolTestStubs.swift @@ -1,6 +1,6 @@ import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit actor RecordingResponderSink: MCPResponderSink { struct WriteJsonRecord { @@ -350,17 +350,18 @@ typealias StubResourcesReadHandler = StubHandler enum MCPProtocolTestSupport { static let referenceDate = Date(timeIntervalSince1970: 1_700_000_000) + static let defaultTokenId = UUID(uuid: (0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11)) static func makePrincipal( fingerprint: String = "test-fp", - tokenId: UUID? = UUID(uuidString: "11111111-1111-1111-1111-111111111111"), + tokenId: UUID? = nil, scopes: Set = [.toolsRead, .toolsWrite], connectionAccess: ConnectionAccess = .all, label: String = "test" ) -> MCPPrincipal { MCPPrincipal( tokenFingerprint: fingerprint, - tokenId: tokenId, + tokenId: tokenId ?? defaultTokenId, scopes: scopes, connectionAccess: connectionAccess, metadata: MCPPrincipalMetadata(label: label, issuedAt: referenceDate, expiresAt: nil) diff --git a/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift b/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift index b57fb1b316..ac267b09a4 100644 --- a/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift +++ b/TableProTests/Core/MCP/Helpers/MCPTransportTestStubs.swift @@ -1,7 +1,7 @@ import Foundation import Network -import TableProPluginKit @testable import TablePro +import TableProPluginKit actor StubAlwaysAllowAuthenticator: MCPAuthenticator { private let principal: MCPPrincipal @@ -9,7 +9,7 @@ actor StubAlwaysAllowAuthenticator: MCPAuthenticator { init(scopes: Set = [.toolsRead, .toolsWrite, .resourcesRead]) { principal = MCPPrincipal( tokenFingerprint: "stubtoken", - tokenId: UUID(uuidString: "00000000-0000-0000-0000-0000000057AB"), + tokenId: UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x57, 0xAB)), scopes: scopes, metadata: MCPPrincipalMetadata( label: "stub", @@ -38,7 +38,7 @@ actor StubBearerAuthenticator: MCPAuthenticator { self.maxAttempts = maxAttempts principal = MCPPrincipal( tokenFingerprint: "fingerprint", - tokenId: UUID(uuidString: "00000000-0000-0000-0000-0000000057AC"), + tokenId: UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x57, 0xAC)), scopes: [.toolsRead, .toolsWrite, .resourcesRead], metadata: MCPPrincipalMetadata( label: "test", diff --git a/TableProTests/Core/MCP/Identity/MCPIdentityLedgerTests.swift b/TableProTests/Core/MCP/Identity/MCPIdentityLedgerTests.swift index de5ca852d7..a600fe1f6c 100644 --- a/TableProTests/Core/MCP/Identity/MCPIdentityLedgerTests.swift +++ b/TableProTests/Core/MCP/Identity/MCPIdentityLedgerTests.swift @@ -2,10 +2,7 @@ // MCPIdentityLedgerTests.swift // TableProTests // -// An approval belongs to the token that earned it and to the one connection it was given for, and -// it expires on its own. There is no session to hang it on any more, so the ledger key is the -// token id, or the principal's fingerprint when there is no token: two anonymous callers are two -// callers. +// An approval belongs to the token that earned it and to the one connection it was given for. // import Foundation @@ -15,7 +12,7 @@ import Testing @testable import TablePro struct MCPIdentityLedgerTests { - private func principal(tokenId: UUID?, fingerprint: String = "fp") -> MCPPrincipal { + private func principal(tokenId: UUID, fingerprint: String = "fp") -> MCPPrincipal { MCPPrincipal( tokenFingerprint: fingerprint, tokenId: tokenId, @@ -52,41 +49,10 @@ struct MCPIdentityLedgerTests { #expect(await ledger.approvedConnectionIds(principal: subject) == [approved]) } - @Test("An anonymous caller's approval expires once its lifetime elapses") - func anonymousApprovalExpires() async { - let clock = MCPTestClock() - let ledger = MCPApprovalLedger(ttl: .seconds(60), clock: clock, store: MCPInMemoryApprovalStore()) - let connectionId = UUID() - let subject = principal(tokenId: nil, fingerprint: "anon") - - await ledger.record(principal: subject, connectionId: connectionId, approved: true) - await clock.advance(by: .seconds(59)) - #expect(await ledger.isApproved(principal: subject, connectionId: connectionId) == true) - - await clock.advance(by: .seconds(2)) - #expect(await ledger.isApproved(principal: subject, connectionId: connectionId) == false) - #expect(await ledger.approvedConnectionIds(principal: subject).isEmpty) - } - - @Test("The default lifetime of an anonymous approval is half an hour") - func defaultAnonymousApprovalLifetime() async { - let clock = MCPTestClock() - let ledger = MCPApprovalLedger(clock: clock, store: MCPInMemoryApprovalStore()) - let connectionId = UUID() - let subject = principal(tokenId: nil, fingerprint: "anon") - - await ledger.record(principal: subject, connectionId: connectionId, approved: true) - await clock.advance(by: .seconds(1_799)) - #expect(await ledger.isApproved(principal: subject, connectionId: connectionId) == true) - - await clock.advance(by: .seconds(2)) - #expect(await ledger.isApproved(principal: subject, connectionId: connectionId) == false) - } - @Test("An issued token's grant does not expire on a timer") func issuedTokenGrantDoesNotExpire() async { let clock = MCPTestClock() - let ledger = MCPApprovalLedger(ttl: .seconds(60), clock: clock, store: MCPInMemoryApprovalStore()) + let ledger = MCPApprovalLedger(clock: clock, store: MCPInMemoryApprovalStore()) let connectionId = UUID() let subject = principal(tokenId: UUID()) @@ -124,35 +90,6 @@ struct MCPIdentityLedgerTests { #expect(await ledger.isApproved(principal: survivor, connectionId: connectionId) == true) } - @Test("Clearing with no token id drops the anonymous approvals and leaves the issued ones") - func clearWithoutTokenIdDropsAnonymousApprovals() async { - let ledger = MCPApprovalLedger(clock: MCPTestClock(), store: MCPInMemoryApprovalStore()) - let connectionId = UUID() - let anonymous = principal(tokenId: nil, fingerprint: MCPPrincipal.anonymousFingerprint) - let issued = principal(tokenId: UUID(), fingerprint: "issued") - - await ledger.record(principal: anonymous, connectionId: connectionId, approved: true) - await ledger.record(principal: issued, connectionId: connectionId, approved: true) - await ledger.clear(subject: nil) - - #expect(await ledger.isApproved(principal: anonymous, connectionId: connectionId) == false) - #expect(await ledger.isApproved(principal: issued, connectionId: connectionId) == true) - } - - @Test("Two anonymous callers are two callers") - func anonymousPrincipalsAreKeyedByFingerprint() async { - let ledger = MCPApprovalLedger(clock: MCPTestClock(), store: MCPInMemoryApprovalStore()) - let connectionId = UUID() - let first = principal(tokenId: nil, fingerprint: "first") - let second = principal(tokenId: nil, fingerprint: "second") - - await ledger.record(principal: first, connectionId: connectionId, approved: true) - - #expect(first.ledgerKey == "anon:first") - #expect(await ledger.isApproved(principal: first, connectionId: connectionId) == true) - #expect(await ledger.isApproved(principal: second, connectionId: connectionId) == false) - } - @Test("A denial removes any standing approval") func denialRemovesApproval() async { let ledger = MCPApprovalLedger(clock: MCPTestClock(), store: MCPInMemoryApprovalStore()) @@ -169,15 +106,15 @@ struct MCPIdentityLedgerTests { func clearAllDropsEveryApproval() async { let ledger = MCPApprovalLedger(clock: MCPTestClock(), store: MCPInMemoryApprovalStore()) let connectionId = UUID() - let anonymous = principal(tokenId: nil, fingerprint: "anon") - let issued = principal(tokenId: UUID()) + let first = principal(tokenId: UUID(), fingerprint: "first") + let second = principal(tokenId: UUID(), fingerprint: "second") - await ledger.record(principal: anonymous, connectionId: connectionId, approved: true) - await ledger.record(principal: issued, connectionId: connectionId, approved: true) + await ledger.record(principal: first, connectionId: connectionId, approved: true) + await ledger.record(principal: second, connectionId: connectionId, approved: true) await ledger.forgetEveryGrant() - #expect(await ledger.isApproved(principal: anonymous, connectionId: connectionId) == false) - #expect(await ledger.isApproved(principal: issued, connectionId: connectionId) == false) + #expect(await ledger.isApproved(principal: first, connectionId: connectionId) == false) + #expect(await ledger.isApproved(principal: second, connectionId: connectionId) == false) } private func meta(clientName: String, version: String) -> MCPRequestMeta { @@ -300,23 +237,19 @@ struct MCPIdentityLedgerTests { func unnamedClientsGetAName() async { let ledger = MCPClientActivityLedger() let start = Date(timeIntervalSince1970: 1_700_000_000) - let anonymousMeta = MCPRequestMeta( + let unnamedMeta = MCPRequestMeta( protocolVersion: .latest, clientInfo: nil, clientCapabilities: .none, progressToken: nil ) + let subject = principal(tokenId: UUID()) - await ledger.record( - meta: anonymousMeta, - principal: .anonymousLoopback, - address: .loopback, - at: start - ) + await ledger.record(meta: unnamedMeta, principal: subject, address: .loopback, at: start) let entry = await ledger.snapshot(now: start).first #expect(entry?.clientName == MCPClientIdentity.unknownClientName) - #expect(entry?.tokenId == nil) + #expect(entry?.tokenId == subject.tokenId) } @Test("A client identity is a digest, not the token it presented") diff --git a/TableProTests/Core/MCP/Legacy/MCPLegacyEraAdapterTests.swift b/TableProTests/Core/MCP/Legacy/MCPLegacyEraAdapterTests.swift index 510c75351a..fa761f2f14 100644 --- a/TableProTests/Core/MCP/Legacy/MCPLegacyEraAdapterTests.swift +++ b/TableProTests/Core/MCP/Legacy/MCPLegacyEraAdapterTests.swift @@ -700,7 +700,7 @@ enum MCPLegacyTestSupport { static func principal( fingerprint: String = "test-fp", - tokenId: UUID? = nil, + tokenId: UUID = UUID(), scopes: Set = MCPScope.readWriteSet ) -> MCPPrincipal { MCPPrincipal( diff --git a/TableProTests/Core/MCP/Legacy/MCPLegacySessionStoreTests.swift b/TableProTests/Core/MCP/Legacy/MCPLegacySessionStoreTests.swift index 82c0eba5ee..29fcbd6af6 100644 --- a/TableProTests/Core/MCP/Legacy/MCPLegacySessionStoreTests.swift +++ b/TableProTests/Core/MCP/Legacy/MCPLegacySessionStoreTests.swift @@ -477,7 +477,7 @@ struct MCPLegacySessionStoreTests { enum LegacyStoreFixtures { static let start = Date(timeIntervalSince1970: 1_700_000_000) - static func owner(fingerprint: String, tokenId: UUID? = nil) -> MCPLegacySessionOwner { + static func owner(fingerprint: String, tokenId: UUID = UUID()) -> MCPLegacySessionOwner { MCPLegacySessionOwner(tokenId: tokenId, tokenFingerprint: fingerprint) } diff --git a/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift b/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift index fb4e3578c6..bbb4a4893b 100644 --- a/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift +++ b/TableProTests/Core/MCP/MCPAuditLogStorageTests.swift @@ -22,7 +22,7 @@ struct MCPAuditLogStorageTests { private func makeEntry( category: AuditCategory = .tool, - tokenId: UUID? = nil, + tokenId: UUID = UUID(), tokenName: String? = nil, connectionId: UUID? = nil, timestamp: Date = Date(), diff --git a/TableProTests/Core/MCP/MCPAuthPolicyTests.swift b/TableProTests/Core/MCP/MCPAuthPolicyTests.swift index e816298cea..53634a7424 100644 --- a/TableProTests/Core/MCP/MCPAuthPolicyTests.swift +++ b/TableProTests/Core/MCP/MCPAuthPolicyTests.swift @@ -2,11 +2,9 @@ // MCPAuthPolicyTests.swift // TableProTests // -// The protocol is stateless, so an approval can no longer hang off a session id: it is keyed on -// the token that earned it and expires on its own. A second token inherits nothing, and revoking +// An approval is keyed on the token that earned it. A second token inherits nothing, and revoking // a token drops the approvals it was carrying. Scope is enforced here too, not only in the -// settings UI: an anonymous caller may not write and may not administer, whatever scopes it -// claims to hold, and a refusal names the missing scope back to the client (RFC 6750 +// settings UI, and a refusal names the missing scope back to the client (RFC 6750 // `insufficient_scope`). // @@ -59,7 +57,7 @@ struct MCPAuthPolicyTests { } private func makePrincipal( - tokenId: UUID? = UUID(), + tokenId: UUID = UUID(), scopes: Set = MCPScope.fullAccessSet, connectionAccess: ConnectionAccess = .all, fingerprint: String = "fp" @@ -208,23 +206,6 @@ struct MCPAuthPolicyTests { } } - @Test("An anonymous caller may not run the destructive-confirmation tool, admin scope or not") - func anonymousIsRefusedTheAdminTool() async throws { - let policy = makePolicy(makeSnapshot()) - - let decision = try await policy.authorize( - principal: makePrincipal(tokenId: nil, scopes: MCPScope.fullAccessSet), - tool: "confirm_destructive_operation", - connectionId: connectionA - ) - - guard case .deniedInsufficientScope(let required, _) = decision else { - Issue.record("Expected an insufficient scope refusal for an anonymous caller, got \(decision)") - return - } - #expect(required == [.admin]) - } - @Test("An issued token without the admin scope may not run the destructive-confirmation tool") func readWriteTokenIsRefusedTheAdminTool() async throws { let policy = makePolicy(makeSnapshot()) @@ -242,24 +223,6 @@ struct MCPAuthPolicyTests { #expect(required == [.admin]) } - @Test("An anonymous caller may not write, even carrying the write scope") - func anonymousIsRefusedAWrite() async throws { - let policy = makePolicy(makeSnapshot()) - - let decision = try await policy.authorize( - principal: makePrincipal(tokenId: nil, scopes: MCPScope.readWriteSet), - tool: "execute_query", - connectionId: connectionA, - sql: "DELETE FROM users WHERE id = 1" - ) - - guard case .deniedInsufficientScope(let required, _) = decision else { - Issue.record("Expected an insufficient scope refusal for an anonymous write, got \(decision)") - return - } - #expect(required == [.toolsWrite]) - } - @Test("A read-only token may not write") func readOnlyTokenIsRefusedAWrite() async throws { let policy = makePolicy(makeSnapshot()) @@ -441,7 +404,7 @@ struct MCPAuthPolicyTests { let clock = MCPTestClock() let policy = makePolicy( makeSnapshot(policy: .askEachTime), - ledger: MCPApprovalLedger(ttl: .seconds(1_800), clock: clock, store: MCPInMemoryApprovalStore()) + ledger: MCPApprovalLedger(clock: clock, store: MCPInMemoryApprovalStore()) ) let principal = makePrincipal() await policy.recordApproval(principal: principal, connectionId: connectionA) diff --git a/TableProTests/Core/MCP/MCPConnectionApprovalTests.swift b/TableProTests/Core/MCP/MCPConnectionApprovalTests.swift index c892c07543..aded2edf43 100644 --- a/TableProTests/Core/MCP/MCPConnectionApprovalTests.swift +++ b/TableProTests/Core/MCP/MCPConnectionApprovalTests.swift @@ -29,7 +29,7 @@ struct MCPConnectionApprovalTests { } private func makePrincipal( - tokenId: UUID? = UUID(), + tokenId: UUID = UUID(), label: String = "token", fingerprint: String = "fp", isBridge: Bool = false @@ -280,27 +280,14 @@ struct MCPConnectionApprovalTests { try await policy.resolveAndAuthorize( principal: principal, tool: "list_tables", connectionId: connectionA ) - await policy.clearAllApprovals() - try await policy.resolveAndAuthorize( + let rebuilt = makePolicy(approval: .oncePerConnection, presenter: presenter, store: store) + try await rebuilt.resolveAndAuthorize( principal: principal, tool: "list_tables", connectionId: connectionA ) #expect(await presenter.askedCount == 1) } - @Test("An anonymous caller's approval is never written to disk") - func anonymousApprovalIsNotPersisted() async throws { - let store = MCPApprovalStore(defaults: Self.scratchDefaults()) - let presenter = RecordingApprovalPresenter(answer: true) - let policy = makePolicy(approval: .oncePerConnection, presenter: presenter, store: store) - - try await policy.resolveAndAuthorize( - principal: .anonymousLoopback, tool: "list_tables", connectionId: connectionA - ) - - #expect(await store.grants().isEmpty) - } - @Test("Forgetting a grant makes the next call ask again") func forgettingAGrantAsksAgain() async throws { let store = MCPApprovalStore(defaults: Self.scratchDefaults()) @@ -395,34 +382,6 @@ struct MCPConnectionApprovalTests { #expect(await denying.grants().isEmpty) } - @Test("An anonymous caller is told its approval lasts only for the session") - func anonymousPromptSaysSessionOnly() async throws { - let presenter = RecordingApprovalPresenter(answer: true) - let policy = makePolicy(approval: .oncePerConnection, presenter: presenter) - - try await policy.resolveAndAuthorize( - principal: .anonymousLoopback, tool: "list_tables", connectionId: connectionA - ) - - #expect(await presenter.lastRequest?.memory == .thisSession) - } - - @Test("An anonymous caller's refusal also stands for a cool-off") - func anonymousDenialIsRememberedBriefly() async throws { - let presenter = RecordingApprovalPresenter(answer: false) - let policy = makePolicy(approval: .oncePerConnection, presenter: presenter) - - for _ in 0..<3 { - await #expect(throws: DatabaseAccessError.self) { - try await policy.resolveAndAuthorize( - principal: .anonymousLoopback, tool: "list_tables", connectionId: connectionA - ) - } - } - - #expect(await presenter.askedCount == 1) - } - private static func scratchDefaults() -> UserDefaults { let suite = "com.TablePro.tests.mcpApprovals." + UUID().uuidString let defaults = UserDefaults(suiteName: suite) ?? .standard diff --git a/TableProTests/Core/MCP/MCPSettingsTests.swift b/TableProTests/Core/MCP/MCPSettingsTests.swift index d5695d5379..3ccb9899fd 100644 --- a/TableProTests/Core/MCP/MCPSettingsTests.swift +++ b/TableProTests/Core/MCP/MCPSettingsTests.swift @@ -11,19 +11,6 @@ struct MCPSettingsTests { #expect(decoded?.enabled == false) } - @Test("Default settings require authentication") - func defaultRequiresAuthentication() { - #expect(MCPSettings.default.requireAuthentication) - #expect(MCPSettings().requireAuthentication) - } - - @Test("Settings JSON without the key decode to authentication required") - func decodesAbsentKeyAsRequired() throws { - let json = Data("{}".utf8) - let decoded = try JSONDecoder().decode(MCPSettings.self, from: json) - #expect(decoded.requireAuthentication) - } - @Test("Listing connections hidden from AI is off until the user turns it on") func hiddenConnectionListingIsOffByDefault() throws { #expect(!MCPSettings.default.allowsHiddenConnectionListing) @@ -37,16 +24,9 @@ struct MCPSettingsTests { #expect(stored.allowsHiddenConnectionListing) } - @Test("Explicit stored false is respected") - func decodesExplicitValue() throws { - let json = Data(#"{"requireAuthentication": false}"#.utf8) - let decoded = try JSONDecoder().decode(MCPSettings.self, from: json) - #expect(!decoded.requireAuthentication) - } - - @Test("The settings carry no remote-access switch, because the server is loopback only") + @Test("The settings carry no remote-access or authentication switch") func thereIsNoRemoteAccessSwitch() throws { - let json = Data(#"{"allowRemoteConnections": true}"#.utf8) + let json = Data(#"{"allowRemoteConnections": true, "requireAuthentication": false}"#.utf8) let decoded = try JSONDecoder().decode(MCPSettings.self, from: json) let reencoded = try JSONEncoder().encode(decoded) let fields = try JSONSerialization.jsonObject(with: reencoded) as? [String: Any] ?? [:] @@ -58,7 +38,6 @@ struct MCPSettingsTests { "maxRowLimit", "queryTimeoutSeconds", "logQueriesInHistory", - "requireAuthentication", "connectionApproval", "allowsHiddenConnectionListing" ]) @@ -85,7 +64,7 @@ struct MCPSettingsTests { @Test("A settings blob written before the approval setting existed keeps asking") func decodesLegacyBlobAsAsking() throws { - let json = Data(#"{"enabled": true, "requireAuthentication": true}"#.utf8) + let json = Data(#"{"enabled": true}"#.utf8) let decoded = try JSONDecoder().decode(MCPSettings.self, from: json) #expect(decoded.connectionApproval == .oncePerConnection) #expect(decoded.connectionApproval.asksBeforeReachingAConnection) @@ -185,62 +164,11 @@ struct MCPSettingsTests { defaultRowLimit: 250, maxRowLimit: 2_000, queryTimeoutSeconds: 45, - logQueriesInHistory: false, - requireAuthentication: true + logQueriesInHistory: false ) let decoded = try JSONDecoder().decode(MCPSettings.self, from: try JSONEncoder().encode(original)) #expect(decoded == original) } - - @Test("Default settings deny anonymous loopback without a token") - func defaultDeniesAnonymousLoopback() async { - let composite = MCPCompositeAuthenticator( - bearer: MCPBearerTokenAuthenticator( - tokenStore: MCPSettingsTestTokenStore(), - rateLimiter: MCPRateLimiter() - ), - requireAuthentication: MCPSettings.default.requireAuthentication - ) - - let decision = await composite.authenticate(authorizationHeader: nil, clientAddress: .loopback) - - guard case .deny(let reason) = decision else { - Issue.record("Expected deny for anonymous loopback under secure default, got \(decision)") - return - } - #expect(reason.httpStatus == 401) - } - - @Test("Turning authentication off still only ever admits an anonymous loopback caller") - func relaxedAuthenticationStaysLoopbackOnly() async { - let composite = MCPCompositeAuthenticator( - bearer: MCPBearerTokenAuthenticator( - tokenStore: MCPSettingsTestTokenStore(), - rateLimiter: MCPRateLimiter() - ), - requireAuthentication: false - ) - - let loopback = await composite.authenticate(authorizationHeader: nil, clientAddress: .loopback) - let remote = await composite.authenticate(authorizationHeader: nil, clientAddress: .remote("10.0.0.5")) - - guard case .allow(let principal) = loopback else { - Issue.record("Expected an anonymous loopback allow, got \(loopback)") - return - } - #expect(principal.isAnonymous) - #expect(principal.scopes == MCPScope.readOnlySet) - guard case .deny = remote else { - Issue.record("Expected a remote caller to be denied, got \(remote)") - return - } - } -} - -actor MCPSettingsTestTokenStore: MCPTokenStoreProtocol { - func validateBearerToken(_ token: String) async -> Result { - .failure(.unknownToken) - } } diff --git a/TableProTests/Core/MCP/MCPTokenStoreTests.swift b/TableProTests/Core/MCP/MCPTokenStoreTests.swift index 7e9c6626a7..262e0706e8 100644 --- a/TableProTests/Core/MCP/MCPTokenStoreTests.swift +++ b/TableProTests/Core/MCP/MCPTokenStoreTests.swift @@ -586,9 +586,7 @@ struct MCPTokenStoreTests { #expect(permissions.scopes.contains(.connectionsDisplay) == false) } #expect(MCPPrincipal.inAppAssistant.has(.connectionsDisplay) == false) - #expect(MCPPrincipal.anonymousLoopback.has(.connectionsDisplay) == false) #expect(MCPPrincipal.inAppAssistant.scopes == MCPScope.fullAccessSet) - #expect(MCPScope.connectionsDisplay.requiresIssuedToken) #expect(MCPScope.optionalGrants == [.connectionsDisplay]) } diff --git a/TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift index 72a187b71a..18b00cf477 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/ListConnectionsToolTests.swift @@ -20,29 +20,19 @@ struct ListConnectionsToolTests { ) } - private func callForDisplay( - scopes: Set, - settingOn: Bool, - principal: MCPPrincipal? = nil - ) async throws -> MCPToolCallResult { + private func callForDisplay(scopes: Set, settingOn: Bool) async throws -> MCPToolCallResult { try await tool.call( arguments: .object(["purpose": .string("display")]), - context: MCPToolTestHarness.context( - principal: principal ?? MCPToolTestHarness.principal(scopes: scopes) - ), + context: MCPToolTestHarness.context(principal: MCPToolTestHarness.principal(scopes: scopes)), services: MCPToolTestHarness.services( settings: MCPSettings(allowsHiddenConnectionListing: settingOn) ) ) } - private func expectDisplayRefused( - scopes: Set, - settingOn: Bool, - principal: MCPPrincipal? = nil - ) async { + private func expectDisplayRefused(scopes: Set, settingOn: Bool) async { do { - _ = try await callForDisplay(scopes: scopes, settingOn: settingOn, principal: principal) + _ = try await callForDisplay(scopes: scopes, settingOn: settingOn) Issue.record("Expected purpose display to be refused") } catch let error as MCPProtocolError { let required = error.data?["requiredScopes"]?.arrayValue?.compactMap { $0.stringValue } @@ -120,17 +110,6 @@ struct ListConnectionsToolTests { await expectDisplayRefused(scopes: [.toolsRead, .connectionsDisplay], settingOn: false) } - @Test("An anonymous caller never gets purpose display") - func anonymousNeverGetsDisplay() async { - let anonymous = MCPPrincipal( - tokenFingerprint: MCPPrincipal.anonymousFingerprint, - tokenId: nil, - scopes: [.toolsRead, .connectionsDisplay], - metadata: MCPPrincipal.anonymousLoopback.metadata - ) - await expectDisplayRefused(scopes: [], settingOn: true, principal: anonymous) - } - @Test("With the scope and the setting on, purpose display lists display fields only") func displayWithScopeAndSetting() async throws { let result = try await callForDisplay(scopes: [.toolsRead, .connectionsDisplay], settingOn: true) diff --git a/TableProTests/Core/MCP/Protocol/Tools/MCPStatementConsentGuardTests.swift b/TableProTests/Core/MCP/Protocol/Tools/MCPStatementConsentGuardTests.swift index 07e0de1320..a1f79421cb 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/MCPStatementConsentGuardTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/MCPStatementConsentGuardTests.swift @@ -22,7 +22,7 @@ struct MCPStatementConsentGuardTests { return (try? String(contentsOf: url, encoding: .utf8)) ?? "" }() - private func principal(scopes: Set, tokenId: UUID?) -> MCPPrincipal { + private func principal(scopes: Set, tokenId: UUID) -> MCPPrincipal { MCPPrincipal( tokenFingerprint: "test", tokenId: tokenId, @@ -42,16 +42,6 @@ struct MCPStatementConsentGuardTests { #expect(effective.contains(.mayWrite)) } - @Test("An anonymous caller cannot pre-clear even holding admin") - func anonymousAdminCannotPreClear() { - let effective = MCPAuthPolicy.effectiveCapabilities( - [.mayWrite, .preCleared, .confirmationPreCleared], - for: principal(scopes: [.toolsWrite, .admin], tokenId: nil) - ) - #expect(!effective.contains(.confirmationPreCleared)) - #expect(!effective.contains(.preCleared)) - } - @Test("An admin-scoped token keeps its pre-clearance") func adminTokenKeepsPreClearance() { let effective = MCPAuthPolicy.effectiveCapabilities( diff --git a/TableProTests/Core/MCP/Protocol/Tools/MCPToolTestHarness.swift b/TableProTests/Core/MCP/Protocol/Tools/MCPToolTestHarness.swift index a58b9e3818..dfac365354 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/MCPToolTestHarness.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/MCPToolTestHarness.swift @@ -77,7 +77,7 @@ actor ToolTestQueryHistoryStore: QueryHistoryReading { } enum MCPToolTestHarness { - static let tokenId = UUID(uuidString: "0F2A6C41-1111-4222-8333-000000000001") + static let tokenId = UUID(uuid: (0x0F, 0x2A, 0x6C, 0x41, 0x11, 0x11, 0x42, 0x22, 0x83, 0x33, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01)) static func principal( scopes: Set = [.toolsRead, .toolsWrite], diff --git a/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift b/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift index 6ead19dab2..9729e8941e 100644 --- a/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift +++ b/TableProTests/Models/Settings/SettingsFieldDecodingTests.swift @@ -55,11 +55,11 @@ struct SettingsFieldDecodingTests { func mcpKeepsOtherFields() throws { let settings = try decode( MCPSettings.self, - #"{"enabled":true,"port":24000,"requireAuthentication":"no","connectionApproval":"whenever"}"# + #"{"enabled":true,"port":24000,"allowsHiddenConnectionListing":"yes","connectionApproval":"whenever"}"# ) #expect(settings.enabled == true) #expect(settings.port == 24_000) - #expect(settings.requireAuthentication == true) + #expect(settings.allowsHiddenConnectionListing == false) #expect(settings.connectionApproval == MCPSettings.default.connectionApproval) }