diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e3e3eaa..57fe91eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed dividers that could not be dragged at all: the user list and privilege panes in Users & Roles, the trigger list in Structure, and the metrics and slow query panes in Server Dashboard. (#1872) - Cancel now stops a connection attempt right away instead of letting it run on in the background. A cancelled connection is also dropped from the last session, so restarting no longer reconnects to a host you gave up on, and it can no longer interrupt a later successful connection. (#1358) - Fixed a crash when clicking a column header on a table whose columns have comments. Sorting such a table quit the app immediately. (#1869) +- MCP tools no longer stop working after the server sits idle for 15 minutes, or after the MCP server is restarted from Settings. TablePro's bridge now starts a new session by itself instead of reusing a dead one, so agents like Claude Code and Cursor keep working without being turned off and on again. (#1881) ### Security diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index e324f2c5c..7f993b0ae 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -442,11 +442,14 @@ CLI/BridgeMain.swift, CLI/BridgeProxy.swift, CLI/Handshake.swift, + Core/Concurrency/OnceTask.swift, + Core/MCP/Session/MCPClock.swift, Core/MCP/Transport/MCPBridgeLogger.swift, Core/MCP/Transport/MCPMessageTransport.swift, Core/MCP/Transport/MCPProtocolError.swift, Core/MCP/Transport/MCPStdioMessageTransport.swift, Core/MCP/Transport/MCPStreamableHttpClientTransport.swift, + Core/MCP/Transport/MCPUpstreamCredentials.swift, Core/MCP/Wire/HttpRequestHead.swift, Core/MCP/Wire/HttpResponseHead.swift, Core/MCP/Wire/JsonRpcCodec.swift, diff --git a/TablePro/CLI/BridgeMain.swift b/TablePro/CLI/BridgeMain.swift index 7e727addd..de8501970 100644 --- a/TablePro/CLI/BridgeMain.swift +++ b/TablePro/CLI/BridgeMain.swift @@ -18,20 +18,27 @@ struct TableProMcpBridge { exit(1) } - guard let endpoint = handshake.endpoint() else { + guard let credentials = handshake.credentials() else { logger.log(.error, "Handshake produced invalid endpoint") emitFatalJsonRpcError(message: "Invalid MCP server endpoint") exit(1) } + let credentialsProvider = MCPCachedUpstreamCredentialsProvider(initial: credentials) { + let refreshed = try await acquirer.acquire() + guard let credentials = refreshed.credentials() else { + throw MCPHandshakeError.fileNotFound + } + logger.log(.info, "Re-acquired MCP handshake after upstream rejected the bridge") + return credentials + } + let upstream = MCPStreamableHttpClientTransport( configuration: MCPStreamableHttpClientConfiguration( - endpoint: endpoint, - bearerToken: handshake.token, - tlsCertFingerprint: handshake.tlsCertFingerprint, requestTimeout: .seconds(60), serverInitiatedStream: false ), + credentialsProvider: credentialsProvider, errorLogger: logger ) diff --git a/TablePro/CLI/Handshake.swift b/TablePro/CLI/Handshake.swift index acefbb292..50c33a70a 100644 --- a/TablePro/CLI/Handshake.swift +++ b/TablePro/CLI/Handshake.swift @@ -102,4 +102,13 @@ extension MCPBridgeHandshake { let scheme = (tls ?? false) ? "https" : "http" return URL(string: "\(scheme)://127.0.0.1:\(port)/mcp") } + + func credentials() -> MCPUpstreamCredentials? { + guard let endpoint = endpoint() else { return nil } + return MCPUpstreamCredentials( + endpoint: endpoint, + bearerToken: token, + tlsCertFingerprint: tlsCertFingerprint + ) + } } diff --git a/TablePro/Core/MCP/MCPServerManager.swift b/TablePro/Core/MCP/MCPServerManager.swift index ba6fa61d2..2a9a0f51d 100644 --- a/TablePro/Core/MCP/MCPServerManager.swift +++ b/TablePro/Core/MCP/MCPServerManager.swift @@ -143,7 +143,11 @@ final class MCPServerManager { startDispatchLoop(transport: newTransport, dispatcher: newDispatcher, generation: generation) startStateLoop(transport: newTransport, generation: generation) - startSessionEventsLoop(sessionStore: newSessionStore, generation: generation) + startSessionEventsLoop( + sessionStore: newSessionStore, + authPolicy: services.authPolicy, + generation: generation + ) await registerRevocationObserver( tokenStore: newTokenStore, sessionStore: newSessionStore, @@ -273,7 +277,11 @@ final class MCPServerManager { } } - private func startSessionEventsLoop(sessionStore: MCPSessionStore, generation: Int) { + private func startSessionEventsLoop( + sessionStore: MCPSessionStore, + authPolicy: MCPAuthPolicy, + generation: Int + ) { sessionEventsTask?.cancel() sessionEventsTask = Task { [weak self] in let stream = await sessionStore.events @@ -281,6 +289,9 @@ final class MCPServerManager { guard let self else { return } guard await self.isCurrentGeneration(generation) else { return } Self.logger.debug("Session event: \(String(describing: event), privacy: .public)") + if case .terminated(let sessionId, _) = event { + await authPolicy.clearSession(sessionId.rawValue) + } await self.refreshClients() } } diff --git a/TablePro/Core/MCP/Transport/MCPMessageTransport.swift b/TablePro/Core/MCP/Transport/MCPMessageTransport.swift index 64d4fc870..d56ce2082 100644 --- a/TablePro/Core/MCP/Transport/MCPMessageTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPMessageTransport.swift @@ -14,5 +14,6 @@ public enum MCPTransportError: Error, Sendable, Equatable { case invalidEndpoint case authentication(httpStatus: Int, message: String) case sessionExpired + case unreachable(detail: String) case timeout } diff --git a/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift b/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift index 31ee6b1b3..a3096f273 100644 --- a/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift +++ b/TablePro/Core/MCP/Transport/MCPStreamableHttpClientTransport.swift @@ -3,45 +3,71 @@ import Foundation import Security public struct MCPStreamableHttpClientConfiguration: Sendable { - public let endpoint: URL - public let bearerToken: String - public let tlsCertFingerprint: String? public let requestTimeout: Duration public let serverInitiatedStream: Bool + public let keepaliveInterval: Duration? public init( - endpoint: URL, - bearerToken: String, - tlsCertFingerprint: String? = nil, requestTimeout: Duration = .seconds(60), - serverInitiatedStream: Bool = false + serverInitiatedStream: Bool = false, + keepaliveInterval: Duration? = .seconds(300) ) { - self.endpoint = endpoint - self.bearerToken = bearerToken - self.tlsCertFingerprint = tlsCertFingerprint self.requestTimeout = requestTimeout self.serverInitiatedStream = serverInitiatedStream + self.keepaliveInterval = keepaliveInterval } } +public enum MCPUpstreamRecoveryError: Error, Sendable, Equatable { + case noCachedInitialize + case reinitializeFailed(status: Int) + case initializedNotificationFailed(status: Int) +} + +enum MCPUpstreamRecoveryReason: Sendable, Equatable { + case sessionExpired + case credentialsRejected +} + public actor MCPStreamableHttpClientTransport: MCPMessageTransport { nonisolated public let inbound: AsyncThrowingStream nonisolated private let continuation: AsyncThrowingStream.Continuation + private static let initializeMethod = "initialize" + private static let initializedNotificationMethod = "notifications/initialized" + private static let pingMethod = "ping" + private static let recoveryKey = "upstream" + private static let sessionTerminationTimeout: TimeInterval = 2 + private static let unavailableMessage = + "TablePro's MCP server is not reachable. Make sure TablePro is running and the MCP server is enabled in Settings > Integrations." + private let configuration: MCPStreamableHttpClientConfiguration + private let credentialsProvider: any MCPUpstreamCredentialsProviding + private let clock: any MCPClock private let urlSession: URLSession private let errorLogger: (any MCPBridgeLogger)? + private let recoveryCoordinator = OnceTask() + private var sessionId: String? + private var sessionGeneration = 0 + private var cachedInitializeRequest: JsonRpcRequest? + private var cachedInitializedNotification: JsonRpcNotification? + private var negotiatedProtocolVersion: String? private var isClosed = false private var serverInitiatedStreamOpen = false + private var keepaliveStarted = false private var tasks: [Task] = [] public init( configuration: MCPStreamableHttpClientConfiguration, + credentialsProvider: any MCPUpstreamCredentialsProviding, + clock: any MCPClock = MCPSystemClock(), urlSession: URLSession? = nil, errorLogger: (any MCPBridgeLogger)? = nil ) { self.configuration = configuration + self.credentialsProvider = credentialsProvider + self.clock = clock self.errorLogger = errorLogger let (stream, continuation) = AsyncThrowingStream.makeStream() @@ -54,12 +80,11 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = TimeInterval(configuration.requestTimeout.components.seconds) config.timeoutIntervalForResource = TimeInterval(configuration.requestTimeout.components.seconds) - if let fingerprint = configuration.tlsCertFingerprint { - let delegate = CertificatePinningDelegate(expectedFingerprint: fingerprint, errorLogger: errorLogger) - self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) - } else { - self.urlSession = URLSession(configuration: config) - } + let delegate = CertificatePinningDelegate( + expectedFingerprint: { await credentialsProvider.currentCredentials().tlsCertFingerprint }, + errorLogger: errorLogger + ) + self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) } } @@ -68,7 +93,8 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { throw MCPTransportError.closed } - let requestId = Self.requestId(of: message) + cacheHandshakeState(from: message) + let body: Data do { body = try JsonRpcCodec.encode(message) @@ -76,9 +102,11 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { throw MCPTransportError.writeFailed(detail: String(describing: error)) } + startKeepaliveIfNeeded() + let task: Task = Task { [weak self] in guard let self else { return } - await self.dispatch(body: body, requestId: requestId) + await self.dispatch(message: message, body: body) } trackTask(task) } @@ -104,6 +132,9 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { return } isClosed = true + + await terminateUpstreamSession() + let pending = tasks tasks.removeAll() for task in pending { @@ -118,41 +149,53 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { tasks.append(task) } - private func setSessionId(_ value: String) { - sessionId = value - } - - private func currentSessionId() -> String? { - sessionId + private func cacheHandshakeState(from message: JsonRpcMessage) { + switch message { + case .request(let request) where request.method == Self.initializeMethod: + cachedInitializeRequest = request + case .notification(let notification) where notification.method == Self.initializedNotificationMethod: + cachedInitializedNotification = notification + default: + break + } } - private func dispatch(body: Data, requestId: JsonRpcId?) async { + private func dispatch(message: JsonRpcMessage, body: Data) async { + let generation = sessionGeneration do { - try await performRequest(body: body, requestId: requestId) + try await performRequest(message: message, body: body, allowRecovery: true) + } catch let error as MCPTransportError { + await handleTransportFailure(error, message: message, body: body, generation: generation) } catch { - await handleSendError(error: error, requestId: requestId) + await handleSendError(error: error, requestId: Self.requestId(of: message)) } } - private func performRequest(body: Data, requestId: JsonRpcId?) async throws { - var request = URLRequest(url: configuration.endpoint) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") - request.setValue("Bearer \(configuration.bearerToken)", forHTTPHeaderField: "Authorization") - if let sessionId = currentSessionId() { - request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") + private func performRequest(message: JsonRpcMessage, body: Data, allowRecovery: Bool) async throws { + let requestId = Self.requestId(of: message) + let isInitialize = Self.method(of: message) == Self.initializeMethod + let credentials = await credentialsProvider.currentCredentials() + let request = makePostRequest(credentials: credentials, body: body, isInitialize: isInitialize) + + let bytes: URLSession.AsyncBytes + let response: URLResponse + do { + (bytes, response) = try await urlSession.bytes(for: request) + } catch { + throw Self.mapRequestFailure(error) } - let (bytes, response) = try await urlSession.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw MCPTransportError.readFailed(detail: "non-HTTP response") } + let status = httpResponse.statusCode + if allowRecovery, let recovery = recoveryError(forStatus: status, isInitialize: isInitialize) { + throw recovery + } + captureSessionIdIfPresent(from: httpResponse) - let status = httpResponse.statusCode let contentType = headerValue(httpResponse, name: "Content-Type")?.lowercased() ?? "" if (200..<300).contains(status) { @@ -160,18 +203,11 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { try await consumeSseBytes(bytes) return } - if contentType.contains("application/json") { - let data = try await collectBytes(bytes) - if data.isEmpty { - return - } - pushJsonBody(data, fallbackId: requestId) - return - } let data = try await collectBytes(bytes) if data.isEmpty { return } + captureNegotiatedProtocolVersion(from: data, isInitialize: isInitialize) pushJsonBody(data, fallbackId: requestId) return } @@ -185,13 +221,278 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { ) } + private func makePostRequest( + credentials: MCPUpstreamCredentials, + body: Data, + isInitialize: Bool + ) -> URLRequest { + var request = URLRequest(url: credentials.endpoint) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(credentials.bearerToken)", forHTTPHeaderField: "Authorization") + guard !isInitialize else { + return request + } + if let sessionId { + request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") + } + if let negotiatedProtocolVersion { + request.setValue(negotiatedProtocolVersion, forHTTPHeaderField: "MCP-Protocol-Version") + } + return request + } + + private func recoveryError(forStatus status: Int, isInitialize: Bool) -> MCPTransportError? { + guard cachedInitializeRequest != nil else { + return nil + } + if status == 401 { + return .authentication(httpStatus: status, message: "Upstream rejected the bridge token") + } + if status == 404, !isInitialize, sessionId != nil { + return .sessionExpired + } + return nil + } + + private func handleTransportFailure( + _ error: MCPTransportError, + message: JsonRpcMessage, + body: Data, + generation: Int + ) async { + if Task.isCancelled || isClosed { + return + } + + guard cachedInitializeRequest != nil, let reason = Self.recoveryReason(for: error) else { + await handleSendError(error: error, requestId: Self.requestId(of: message)) + return + } + + await recoverAndReplay(reason: reason, message: message, body: body, generation: generation) + } + + private func recoverAndReplay( + reason: MCPUpstreamRecoveryReason, + message: JsonRpcMessage, + body: Data, + generation: Int + ) async { + do { + try await runRecovery( + reason: reason, + originatingMethod: Self.method(of: message), + generation: generation + ) + } catch { + errorLogger?.log(.error, "Upstream session recovery failed: \(error)") + yieldUpstreamUnavailable(requestId: Self.requestId(of: message)) + return + } + + guard !isClosed, !Task.isCancelled else { + return + } + + do { + try await performRequest(message: message, body: body, allowRecovery: false) + } catch { + await handleSendError(error: error, requestId: Self.requestId(of: message)) + } + } + + private func runRecovery( + reason: MCPUpstreamRecoveryReason, + originatingMethod: String?, + generation: Int + ) async throws { + try await recoveryCoordinator.execute(key: Self.recoveryKey) { [weak self] in + guard let self else { return } + try await self.performRecovery( + reason: reason, + originatingMethod: originatingMethod, + generation: generation + ) + } + } + + private func performRecovery( + reason: MCPUpstreamRecoveryReason, + originatingMethod: String?, + generation: Int + ) async throws { + guard sessionGeneration == generation else { + return + } + + if reason == .credentialsRejected { + _ = try await credentialsProvider.refreshCredentials() + } + + if originatingMethod != Self.initializeMethod { + try await reinitializeSession() + } + + sessionGeneration += 1 + } + + private func reinitializeSession() async throws { + guard let cached = cachedInitializeRequest else { + throw MCPUpstreamRecoveryError.noCachedInitialize + } + + let internalId = Self.internalRequestId(purpose: "reinit") + let outcome = try await executeInternalExchange( + message: .request( + JsonRpcRequest(id: internalId, method: Self.initializeMethod, params: cached.params) + ) + ) + + guard (200..<300).contains(outcome.status), + case .successResponse(let success)? = outcome.message, + success.id == internalId, + let refreshedSessionId = outcome.sessionId else { + throw MCPUpstreamRecoveryError.reinitializeFailed(status: outcome.status) + } + + sessionId = refreshedSessionId + negotiatedProtocolVersion = success.result["protocolVersion"]?.stringValue + + if let notification = cachedInitializedNotification { + let acknowledgement = try await executeInternalExchange(message: .notification(notification)) + guard (200..<300).contains(acknowledgement.status) else { + throw MCPUpstreamRecoveryError.initializedNotificationFailed(status: acknowledgement.status) + } + } + + errorLogger?.log(.info, "Re-established upstream MCP session after \(refreshedSessionId.prefix(8))") + } + + private func executeInternalExchange(message: JsonRpcMessage) async throws -> InternalExchangeOutcome { + let body: Data + do { + body = try JsonRpcCodec.encode(message) + } catch { + throw MCPTransportError.writeFailed(detail: String(describing: error)) + } + + let isInitialize = Self.method(of: message) == Self.initializeMethod + let credentials = await credentialsProvider.currentCredentials() + let request = makePostRequest(credentials: credentials, body: body, isInitialize: isInitialize) + + let bytes: URLSession.AsyncBytes + let response: URLResponse + do { + (bytes, response) = try await urlSession.bytes(for: request) + } catch { + throw Self.mapRequestFailure(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw MCPTransportError.readFailed(detail: "non-HTTP response") + } + + let data = try await collectBytes(bytes) + let contentType = headerValue(httpResponse, name: "Content-Type")?.lowercased() ?? "" + + return InternalExchangeOutcome( + status: httpResponse.statusCode, + sessionId: headerValue(httpResponse, name: "Mcp-Session-Id"), + message: await Self.decodeInternalBody(data, contentType: contentType) + ) + } + + private func startKeepaliveIfNeeded() { + guard !keepaliveStarted, configuration.keepaliveInterval != nil else { + return + } + keepaliveStarted = true + + let task: Task = Task { [weak self] in + guard let self else { return } + await self.runKeepaliveLoop() + } + trackTask(task) + } + + private func runKeepaliveLoop() async { + guard let interval = configuration.keepaliveInterval else { + return + } + while !Task.isCancelled, !isClosed { + do { + try await clock.sleep(for: interval) + } catch { + return + } + guard !Task.isCancelled, !isClosed else { + return + } + await sendKeepalivePing() + } + } + + private func sendKeepalivePing() async { + guard sessionId != nil else { + return + } + + let generation = sessionGeneration + let ping = JsonRpcMessage.request( + JsonRpcRequest(id: Self.internalRequestId(purpose: "ping"), method: Self.pingMethod, params: nil) + ) + + let outcome: InternalExchangeOutcome + do { + outcome = try await executeInternalExchange(message: ping) + } catch { + errorLogger?.log(.warning, "Keepalive ping failed: \(error)") + return + } + + guard let failure = recoveryError(forStatus: outcome.status, isInitialize: false), + let reason = Self.recoveryReason(for: failure) else { + return + } + + do { + try await runRecovery( + reason: reason, + originatingMethod: Self.pingMethod, + generation: generation + ) + } catch { + errorLogger?.log(.warning, "Keepalive recovery failed: \(error)") + } + } + + private func terminateUpstreamSession() async { + guard let sessionId else { + return + } + + let credentials = await credentialsProvider.currentCredentials() + var request = URLRequest(url: credentials.endpoint) + request.httpMethod = "DELETE" + request.timeoutInterval = Self.sessionTerminationTimeout + request.setValue("Bearer \(credentials.bearerToken)", forHTTPHeaderField: "Authorization") + request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") + + _ = try? await urlSession.bytes(for: request) + self.sessionId = nil + } + private func runServerInitiatedStream() async { do { - var request = URLRequest(url: configuration.endpoint) + let credentials = await credentialsProvider.currentCredentials() + var request = URLRequest(url: credentials.endpoint) request.httpMethod = "GET" request.setValue("text/event-stream", forHTTPHeaderField: "Accept") - request.setValue("Bearer \(configuration.bearerToken)", forHTTPHeaderField: "Authorization") - if let sessionId = currentSessionId() { + request.setValue("Bearer \(credentials.bearerToken)", forHTTPHeaderField: "Authorization") + if let sessionId { request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id") } @@ -317,14 +618,37 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { guard let requestId else { return } + if case MCPTransportError.unreachable = error { + yieldUpstreamUnavailable(requestId: requestId) + return + } let protocolError = MCPProtocolError.internalError(detail: String(describing: error)) let response = protocolError.toJsonRpcErrorResponse(id: requestId) continuation.yield(.errorResponse(response)) } + private func yieldUpstreamUnavailable(requestId: JsonRpcId?) { + guard let requestId else { + return + } + let response = JsonRpcErrorResponse( + id: requestId, + error: JsonRpcError(code: JsonRpcErrorCode.serverError, message: Self.unavailableMessage) + ) + continuation.yield(.errorResponse(response)) + } + private func captureSessionIdIfPresent(from response: HTTPURLResponse) { guard let value = headerValue(response, name: "Mcp-Session-Id") else { return } - setSessionId(value) + sessionId = value + } + + private func captureNegotiatedProtocolVersion(from data: Data, isInitialize: Bool) { + guard isInitialize, + let message = try? JsonRpcCodec.decode(data), + case .successResponse(let success) = message, + let version = success.result["protocolVersion"]?.stringValue else { return } + negotiatedProtocolVersion = version } private func headerValue(_ response: HTTPURLResponse, name: String) -> String? { @@ -338,6 +662,52 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { return nil } + private static func decodeInternalBody(_ data: Data, contentType: String) async -> JsonRpcMessage? { + guard !data.isEmpty else { return nil } + + guard contentType.contains("text/event-stream") else { + return try? JsonRpcCodec.decode(data) + } + + let decoder = SseDecoder() + let frames = await decoder.feed(data) + for frame in frames { + guard let payload = frame.data.data(using: .utf8), + let message = try? JsonRpcCodec.decode(payload) else { continue } + return message + } + return nil + } + + private static func internalRequestId(purpose: String) -> JsonRpcId { + .string("__tablepro_bridge_\(purpose)_\(UUID().uuidString)") + } + + private static func recoveryReason(for error: MCPTransportError) -> MCPUpstreamRecoveryReason? { + switch error { + case .sessionExpired: + return .sessionExpired + case .authentication, .unreachable: + return .credentialsRejected + default: + return nil + } + } + + private static func mapRequestFailure(_ error: Error) -> MCPTransportError { + guard let urlError = error as? URLError else { + return .readFailed(detail: String(describing: error)) + } + switch urlError.code { + case .cannotConnectToHost, .cannotFindHost, .networkConnectionLost, .notConnectedToInternet: + return .unreachable(detail: urlError.localizedDescription) + case .timedOut: + return .timeout + default: + return .readFailed(detail: urlError.localizedDescription) + } + } + private static func requestId(of message: JsonRpcMessage) -> JsonRpcId? { switch message { case .request(let request): @@ -351,6 +721,17 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { } } + private static func method(of message: JsonRpcMessage) -> String? { + switch message { + case .request(let request): + return request.method + case .notification(let notification): + return notification.method + case .successResponse, .errorResponse: + return nil + } + } + private static func protocolError(forStatus status: Int, body: Data, challenge: String) -> MCPProtocolError { let detail = String(data: body, encoding: .utf8) ?? "HTTP \(status)" switch status { @@ -376,13 +757,22 @@ public actor MCPStreamableHttpClientTransport: MCPMessageTransport { return .internalError(detail: detail) } } + + private struct InternalExchangeOutcome: Sendable { + let status: Int + let sessionId: String? + let message: JsonRpcMessage? + } } private final class CertificatePinningDelegate: NSObject, URLSessionDelegate { - private let expectedFingerprint: String + private let expectedFingerprint: @Sendable () async -> String? private let errorLogger: (any MCPBridgeLogger)? - init(expectedFingerprint: String, errorLogger: (any MCPBridgeLogger)?) { + init( + expectedFingerprint: @escaping @Sendable () async -> String?, + errorLogger: (any MCPBridgeLogger)? + ) { self.expectedFingerprint = expectedFingerprint self.errorLogger = errorLogger } @@ -396,6 +786,10 @@ private final class CertificatePinningDelegate: NSObject, URLSessionDelegate { return (.performDefaultHandling, nil) } + guard let expected = await expectedFingerprint() else { + return (.performDefaultHandling, nil) + } + guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], let leaf = chain.first else { errorLogger?.log(.error, "TLS pinning: empty cert chain") @@ -403,7 +797,7 @@ private final class CertificatePinningDelegate: NSObject, URLSessionDelegate { } let fingerprint = Self.sha256Fingerprint(of: leaf) - if fingerprint.caseInsensitiveCompare(expectedFingerprint) != .orderedSame { + if fingerprint.caseInsensitiveCompare(expected) != .orderedSame { let prefix = String(fingerprint.prefix(8)) errorLogger?.log(.error, "TLS pinning: cert mismatch (got \(prefix)...)") return (.cancelAuthenticationChallenge, nil) diff --git a/TablePro/Core/MCP/Transport/MCPUpstreamCredentials.swift b/TablePro/Core/MCP/Transport/MCPUpstreamCredentials.swift new file mode 100644 index 000000000..06efdd1ba --- /dev/null +++ b/TablePro/Core/MCP/Transport/MCPUpstreamCredentials.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct MCPUpstreamCredentials: Sendable, Equatable { + public let endpoint: URL + public let bearerToken: String + public let tlsCertFingerprint: String? + + public init(endpoint: URL, bearerToken: String, tlsCertFingerprint: String? = nil) { + self.endpoint = endpoint + self.bearerToken = bearerToken + self.tlsCertFingerprint = tlsCertFingerprint + } +} + +public protocol MCPUpstreamCredentialsProviding: Sendable { + func currentCredentials() async -> MCPUpstreamCredentials + func refreshCredentials() async throws -> MCPUpstreamCredentials +} + +public actor MCPCachedUpstreamCredentialsProvider: MCPUpstreamCredentialsProviding { + private var cached: MCPUpstreamCredentials + private let reacquire: @Sendable () async throws -> MCPUpstreamCredentials + + public init( + initial: MCPUpstreamCredentials, + reacquire: @escaping @Sendable () async throws -> MCPUpstreamCredentials + ) { + self.cached = initial + self.reacquire = reacquire + } + + public func currentCredentials() async -> MCPUpstreamCredentials { + cached + } + + public func refreshCredentials() async throws -> MCPUpstreamCredentials { + let refreshed = try await reacquire() + cached = refreshed + return refreshed + } +} diff --git a/TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift b/TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift index d402bff4e..940f36300 100644 --- a/TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift +++ b/TableProTests/Core/MCP/Integration/MCPBridgeIntegrationTests.swift @@ -56,64 +56,102 @@ final class MCPBridgeIntegrationTests: XCTestCase { XCTAssertEqual(toolsSuccess.result["echo"]?.stringValue, "tools/list") } - func testIdleSessionEvictionReturnsSessionNotFoundError() async throws { + func testIdleSessionEvictionRecoversTransparently() async throws { let clock = MCPTestClock(start: Date(timeIntervalSince1970: 1_700_000_000)) let policy = MCPSessionPolicy( idleTimeout: .seconds(60), maxSessions: 16, cleanupInterval: .seconds(60) ) - let harness = try await BridgeHarness.start( + let store = MCPSessionStore(policy: policy, clock: clock) + let serverTransport = MCPHttpServerTransport( + configuration: MCPHttpServerConfiguration.loopback(port: 0), + sessionStore: store, authenticator: StubAlwaysAllowAuthenticator(), - clock: clock, - sessionPolicy: policy + clock: clock ) - defer { harness.shutdown() } + let stateStream = serverTransport.listenerState + let stateTask = Task { + for await state in stateStream { + if case .running(let port) = state { return port } + if case .failed = state { return nil } + } + return nil + } + try await serverTransport.start() + guard let port = await stateTask.value, port != 0 else { + XCTFail("server did not start") + return + } + defer { Task { await serverTransport.stop() } } + let observed = ObservedMethods() let consumer = StubExchangeConsumer() - await consumer.start(transport: harness.serverTransport) { exchange in + await consumer.start(transport: serverTransport) { exchange in switch exchange.message { case .request(let request): - let response = JsonRpcMessage.successResponse( - JsonRpcSuccessResponse(id: request.id, result: .object(["ok": .bool(true)])) + await observed.record(request.method) + await exchange.responder.respond( + .successResponse(JsonRpcSuccessResponse(id: request.id, result: .object(["ok": .bool(true)]))), + sessionId: exchange.context.sessionId ) - await exchange.responder.respond(response, sessionId: exchange.context.sessionId) + case .notification(let notification): + await observed.record(notification.method) + await exchange.responder.acknowledgeAccepted() default: await exchange.responder.respondError(.invalidRequest(detail: "unsupported"), requestId: nil) } } defer { Task { await consumer.stop() } } - let initRequest = JsonRpcMessage.request( - JsonRpcRequest(id: .number(10), method: "initialize", params: nil) - ) - try await harness.writeFromHost(initRequest) - - let initResponse = try await harness.readNextResponse() - guard case .successResponse = initResponse else { - XCTFail("Expected initialize success, got \(initResponse)") + guard let url = URL(string: "http://127.0.0.1:\(port)/mcp") else { + XCTFail("Failed to build URL") return } - let initialSessionCount = await harness.sessionStore.count() - XCTAssertEqual(initialSessionCount, 1) + let credentials = MCPUpstreamCredentials(endpoint: url, bearerToken: Self.bearerToken) + let client = MCPStreamableHttpClientTransport( + configuration: MCPStreamableHttpClientConfiguration( + requestTimeout: .seconds(5), + serverInitiatedStream: false, + keepaliveInterval: nil + ), + credentialsProvider: MCPCachedUpstreamCredentialsProvider(initial: credentials) { credentials }, + errorLogger: nil + ) + defer { Task { await client.close() } } + + try await client.send(.request(JsonRpcRequest(id: .number(10), method: "initialize", params: nil))) + _ = try await Self.firstInbound(of: client, timeout: 3.0) + try await client.send(.notification(JsonRpcNotification(method: "notifications/initialized", params: nil))) + try await Self.waitUntil { await observed.methods.contains("notifications/initialized") } + + let sessionsBeforeEviction = await store.allSessions() + XCTAssertEqual(sessionsBeforeEviction.count, 1) + let evictedSessionId = sessionsBeforeEviction.first?.id await clock.advance(by: .seconds(120)) - await harness.sessionStore.runCleanupPass() - let postCleanupCount = await harness.sessionStore.count() + await store.runCleanupPass() + let postCleanupCount = await store.count() XCTAssertEqual(postCleanupCount, 0) - let followUp = JsonRpcMessage.request( - JsonRpcRequest(id: .number(11), method: "tools/call", params: nil) - ) - try await harness.writeFromHost(followUp) - - let response = try await harness.readNextResponse() - guard case .errorResponse(let envelope) = response else { - XCTFail("Expected errorResponse, got \(response)") + try await client.send(.request(JsonRpcRequest(id: .number(11), method: "tools/call", params: nil))) + let response = try await Self.firstInbound(of: client, timeout: 3.0) + guard case .successResponse(let success) = response else { + XCTFail("Expected the follow-up call to recover transparently, got \(response)") return } - XCTAssertEqual(envelope.id, .number(11)) - XCTAssertEqual(envelope.error.code, JsonRpcErrorCode.sessionNotFound) + XCTAssertEqual(success.id, .number(11)) + + let sessionsAfterRecovery = await store.allSessions() + XCTAssertEqual(sessionsAfterRecovery.count, 1) + XCTAssertNotEqual(sessionsAfterRecovery.first?.id, evictedSessionId) + + let methods = await observed.methods + XCTAssertEqual( + methods.suffix(3), + ["initialize", "notifications/initialized", "tools/call"], + "The bridge must replay the full handshake before retrying the call" + ) } func testServerReturning404WithGarbageBodyIsWrappedAsJsonRpcError() async throws { @@ -131,13 +169,16 @@ final class MCPBridgeIntegrationTests: XCTestCase { return } let configuration = MCPStreamableHttpClientConfiguration( - endpoint: url, - bearerToken: Self.bearerToken, - tlsCertFingerprint: nil, requestTimeout: .seconds(5), - serverInitiatedStream: false + serverInitiatedStream: false, + keepaliveInterval: nil + ) + let credentials = MCPUpstreamCredentials(endpoint: url, bearerToken: Self.bearerToken) + let client = MCPStreamableHttpClientTransport( + configuration: configuration, + credentialsProvider: MCPCachedUpstreamCredentialsProvider(initial: credentials) { credentials }, + errorLogger: nil ) - let client = MCPStreamableHttpClientTransport(configuration: configuration, errorLogger: nil) defer { Task { await client.close() } } let request = JsonRpcMessage.request( @@ -205,6 +246,20 @@ final class MCPBridgeIntegrationTests: XCTestCase { } } + private static func waitUntil( + timeout: TimeInterval = 3.0, + condition: @Sendable () async -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await condition() { + return + } + try await Task.sleep(nanoseconds: 20_000_000) + } + throw IntegrationTestError.timeout + } + private static func firstInbound( of transport: MCPStreamableHttpClientTransport, timeout: TimeInterval @@ -234,6 +289,14 @@ private enum IntegrationTestError: Error { case readClosed } +private actor ObservedMethods { + private(set) var methods: [String] = [] + + func record(_ method: String) { + methods.append(method) + } +} + private struct PipePair { let hostInput: FileHandle let bridgeStdin: FileHandle @@ -430,14 +493,18 @@ private final class BridgeHarness: @unchecked Sendable { } let logger = IntegrationBridgeLogger() let clientConfig = MCPStreamableHttpClientConfiguration( - endpoint: url, - bearerToken: MCPBridgeIntegrationTests.bearerToken, - tlsCertFingerprint: nil, requestTimeout: .seconds(5), - serverInitiatedStream: false + serverInitiatedStream: false, + keepaliveInterval: nil + ) + let credentials = MCPUpstreamCredentials( + endpoint: url, + bearerToken: MCPBridgeIntegrationTests.bearerToken ) + let credentialsProvider = MCPCachedUpstreamCredentialsProvider(initial: credentials) { credentials } let clientTransport = MCPStreamableHttpClientTransport( configuration: clientConfig, + credentialsProvider: credentialsProvider, errorLogger: logger ) diff --git a/TableProTests/Core/MCP/MCPAuthPolicyTests.swift b/TableProTests/Core/MCP/MCPAuthPolicyTests.swift index c591cc7f1..482bdb3df 100644 --- a/TableProTests/Core/MCP/MCPAuthPolicyTests.swift +++ b/TableProTests/Core/MCP/MCPAuthPolicyTests.swift @@ -146,6 +146,62 @@ struct MCPAuthPolicyTests { } } + @Test("Recorded approval allows the connection for the rest of the session") + func recordedApprovalAllowsConnection() async throws { + let policy = makePolicy(makeSnapshot(policy: .askEachTime)) + await policy.recordApproval(sessionId: "session", connectionId: connectionA) + + let decision = try await policy.authorize( + principal: makePrincipal(), + tool: "list_tables", + connectionId: connectionA, + sql: nil, + sessionId: "session" + ) + guard case .allowed = decision else { + Issue.record("Expected an approved connection to be allowed, got \(decision)") + return + } + } + + @Test("Clearing a terminated session drops its approvals") + func clearSessionDropsApprovals() async throws { + let policy = makePolicy(makeSnapshot(policy: .askEachTime)) + await policy.recordApproval(sessionId: "session", connectionId: connectionA) + await policy.clearSession("session") + + let decision = try await policy.authorize( + principal: makePrincipal(), + tool: "list_tables", + connectionId: connectionA, + sql: nil, + sessionId: "session" + ) + guard case .requiresUserApproval = decision else { + Issue.record("Expected approval to be required again after the session was cleared, got \(decision)") + return + } + } + + @Test("Clearing an unknown session is a no-op") + func clearUnknownSessionIsNoOp() async throws { + let policy = makePolicy(makeSnapshot(policy: .askEachTime)) + await policy.recordApproval(sessionId: "session", connectionId: connectionA) + await policy.clearSession("other-session") + + let decision = try await policy.authorize( + principal: makePrincipal(), + tool: "list_tables", + connectionId: connectionA, + sql: nil, + sessionId: "session" + ) + guard case .allowed = decision else { + Issue.record("Clearing another session must not drop this session's approvals, got \(decision)") + return + } + } + @Test("Unknown connection denies") func unknownConnectionDenied() async throws { let policy = makePolicy(nil) diff --git a/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift b/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift index 427d4ffa1..552a66965 100644 --- a/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift +++ b/TableProTests/Core/MCP/Transport/MCPStreamableHttpClientTransportTests.swift @@ -232,16 +232,264 @@ final class MCPStreamableHttpClientTransportTests: XCTestCase { await transport.close() } - private func makeTransport() -> MCPStreamableHttpClientTransport { - let url = URL(string: "http://127.0.0.1:\(server.port)/mcp")! + func testSessionExpiryTriggersTransparentReinitializeAndReplay() async throws { + let fake = FakeMcpServer() + await server.setResponder { fake.respond(to: $0) } + + let transport = makeTransport() + try await completeHandshake(transport: transport, fake: fake) + let originalSessionId = fake.currentSessionId + + fake.expireSession() + + try await transport.send(.request( + JsonRpcRequest(id: .number(42), method: "tools/call", params: nil) + )) + + let received = try await firstInbound(transport: transport) + guard case .successResponse(let success) = received else { + XCTFail("Expected the replayed tools/call to succeed, got \(received)") + return + } + XCTAssertEqual(success.id, .number(42)) + XCTAssertEqual(success.result["echo"]?.stringValue, "tools/call") + + XCTAssertEqual(fake.initializeCount, 2) + XCTAssertNotEqual(fake.currentSessionId, originalSessionId) + XCTAssertEqual( + fake.rpcMethods.suffix(3), + ["initialize", "notifications/initialized", "tools/call"] + ) + + await transport.close() + } + + func testRecoveryInitializeResponseNeverReachesTheHost() async throws { + let fake = FakeMcpServer() + await server.setResponder { fake.respond(to: $0) } + + let transport = makeTransport() + try await completeHandshake(transport: transport, fake: fake) + fake.expireSession() + + try await transport.send(.request( + JsonRpcRequest(id: .number(9), method: "tools/list", params: nil) + )) + + let received = try await firstInbound(transport: transport) + guard case .successResponse(let success) = received else { + XCTFail("Expected successResponse, got \(received)") + return + } + XCTAssertEqual(success.id, .number(9), "Recovery handshake must not surface on the host stream") + + await transport.close() + } + + func testConcurrentSessionExpiryCollapsesToSingleReinitialize() async throws { + let fake = FakeMcpServer() + await server.setResponder { fake.respond(to: $0) } + + let transport = makeTransport() + try await completeHandshake(transport: transport, fake: fake) + fake.expireSession() + + let callCount = 5 + for index in 0.. Bool + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return + } + try await Task.sleep(nanoseconds: 20_000_000) + } + throw TransportTestError.timeout + } + + private func makeCredentials(port: UInt16, token: String = "test-token") -> MCPUpstreamCredentials { + MCPUpstreamCredentials( + endpoint: URL(string: "http://127.0.0.1:\(port)/mcp")!, + bearerToken: token + ) + } + + private func makeTransport( + keepaliveInterval: Duration? = nil, + clock: any MCPClock = MCPSystemClock(), + credentialsProvider: (any MCPUpstreamCredentialsProviding)? = nil + ) -> MCPStreamableHttpClientTransport { + let credentials = makeCredentials(port: server.port) + let provider = credentialsProvider + ?? MCPCachedUpstreamCredentialsProvider(initial: credentials) { credentials } let configuration = MCPStreamableHttpClientConfiguration( - endpoint: url, - bearerToken: "test-token", - tlsCertFingerprint: nil, requestTimeout: .seconds(5), - serverInitiatedStream: false + serverInitiatedStream: false, + keepaliveInterval: keepaliveInterval + ) + return MCPStreamableHttpClientTransport( + configuration: configuration, + credentialsProvider: provider, + clock: clock, + errorLogger: nil ) - return MCPStreamableHttpClientTransport(configuration: configuration, errorLogger: nil) } private func firstInbound( @@ -297,6 +545,214 @@ final class MCPStreamableHttpClientTransportTests: XCTestCase { private enum TransportTestError: Error { case timeout + case noQueuedCredentials +} + +private actor QueuedCredentialsProvider: MCPUpstreamCredentialsProviding { + private var current: MCPUpstreamCredentials + private var queued: [MCPUpstreamCredentials] + private(set) var refreshCount = 0 + + init(initial: MCPUpstreamCredentials, queued: [MCPUpstreamCredentials]) { + self.current = initial + self.queued = queued + } + + func currentCredentials() async -> MCPUpstreamCredentials { + current + } + + func refreshCredentials() async throws -> MCPUpstreamCredentials { + refreshCount += 1 + guard !queued.isEmpty else { + throw TransportTestError.noQueuedCredentials + } + current = queued.removeFirst() + return current + } +} + +private final class FakeMcpServer: @unchecked Sendable { + static let protocolVersion = "2025-06-18" + + private let lock = NSLock() + private var activeSessionId: String? + private var sessionCounter = 0 + private var expectedToken = "test-token" + private var recorded: [(method: String?, headers: [(String, String)])] = [] + private var initializes = 0 + private var deletes = 0 + private var refuseInitialize = false + + var rejectInitialize: Bool { + get { + lock.lock() + defer { lock.unlock() } + return refuseInitialize + } + set { + lock.lock() + refuseInitialize = newValue + lock.unlock() + } + } + + var currentSessionId: String? { + lock.lock() + defer { lock.unlock() } + return activeSessionId + } + + var initializeCount: Int { + lock.lock() + defer { lock.unlock() } + return initializes + } + + var deleteCount: Int { + lock.lock() + defer { lock.unlock() } + return deletes + } + + var rpcMethods: [String] { + lock.lock() + defer { lock.unlock() } + return recorded.compactMap(\.method) + } + + func protocolVersionHeader(forRpcMethod method: String) -> String? { + lock.lock() + defer { lock.unlock() } + guard let entry = recorded.last(where: { $0.method == method }) else { return nil } + return entry.headers.first { $0.0.lowercased() == "mcp-protocol-version" }?.1 + } + + func expireSession() { + lock.lock() + activeSessionId = nil + lock.unlock() + } + + func rotateToken(_ token: String) { + lock.lock() + expectedToken = token + lock.unlock() + } + + func respond(to request: MockHttpRequest) -> MockHttpResponse { + if request.method == "DELETE" { + lock.lock() + deletes += 1 + activeSessionId = nil + lock.unlock() + return MockHttpResponse(status: 204, headers: [], body: Data()) + } + + let decoded = try? JsonRpcCodec.decode(request.body) + let rpcMethod = Self.method(of: decoded) + + lock.lock() + recorded.append((method: rpcMethod, headers: request.headers)) + let token = expectedToken + let refusing = refuseInitialize + lock.unlock() + + let authorization = request.headers.first { $0.0.lowercased() == "authorization" }?.1 + guard authorization == "Bearer \(token)" else { + return MockHttpResponse( + status: 401, + headers: [ + ("Content-Type", "text/plain"), + ("WWW-Authenticate", "Bearer realm=\"TablePro\"") + ], + body: Data("Unauthenticated".utf8) + ) + } + + guard let decoded else { + return MockHttpResponse( + status: 400, + headers: [("Content-Type", "text/plain")], + body: Data("Bad request".utf8) + ) + } + + if case .request(let rpc) = decoded, rpc.method == "initialize" { + if refusing { + return MockHttpResponse( + status: 503, + headers: [("Content-Type", "text/plain")], + body: Data("Service unavailable".utf8) + ) + } + return mintSession(for: rpc) + } + + let sessionHeader = request.headers.first { $0.0.lowercased() == "mcp-session-id" }?.1 + guard isKnownSession(sessionHeader) else { + return MockHttpResponse( + status: 404, + headers: [("Content-Type", "text/plain")], + body: Data("Session not found".utf8) + ) + } + + guard case .request(let rpc) = decoded else { + return MockHttpResponse(status: 202, headers: [], body: Data()) + } + + let body = try? JsonRpcCodec.encode(.successResponse( + JsonRpcSuccessResponse(id: rpc.id, result: .object(["echo": .string(rpc.method)])) + )) + return MockHttpResponse( + status: 200, + headers: [("Content-Type", "application/json")], + body: body ?? Data() + ) + } + + private func mintSession(for rpc: JsonRpcRequest) -> MockHttpResponse { + lock.lock() + sessionCounter += 1 + initializes += 1 + let sessionId = "session-\(sessionCounter)" + activeSessionId = sessionId + lock.unlock() + + let body = try? JsonRpcCodec.encode(.successResponse( + JsonRpcSuccessResponse( + id: rpc.id, + result: .object(["protocolVersion": .string(Self.protocolVersion)]) + ) + )) + return MockHttpResponse( + status: 200, + headers: [ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", sessionId) + ], + body: body ?? Data() + ) + } + + private func isKnownSession(_ sessionId: String?) -> Bool { + lock.lock() + defer { lock.unlock() } + guard let activeSessionId, let sessionId else { return false } + return activeSessionId == sessionId + } + + private static func method(of message: JsonRpcMessage?) -> String? { + switch message { + case .request(let request): + return request.method + case .notification(let notification): + return notification.method + default: + return nil + } + } } private struct MockHttpRequest: Sendable { diff --git a/docs/external-api/mcp-clients.mdx b/docs/external-api/mcp-clients.mdx index e0d863925..e395edd92 100644 --- a/docs/external-api/mcp-clients.mdx +++ b/docs/external-api/mcp-clients.mdx @@ -154,9 +154,9 @@ Ask the client to list TablePro tools or call `list_connections`. Success looks If the call fails, the response tells you which layer rejected it: - **stdio process exits immediately**: the bridge could not launch TablePro or got no handshake within 10 seconds. Open TablePro manually and check that **Settings > Integrations** shows the server running. -- **`401 Unauthorized`**: the bridge token is stale. Quit and reopen TablePro to regenerate the handshake. +- **`401 Unauthorized`**: the bridge token is stale. The stdio bridge re-reads the handshake and retries on its own; you only see this if the retry also fails, which means TablePro is not running. - **`403 Forbidden`**: the connection's `externalAccess` is `blocked` or `readOnly`, or the token's allowlist excludes it. Adjust **External Access** in TablePro's connection editor. -- **`404 Session not found`** (JSON-RPC code `-32001`): the session hit the 15-minute idle timeout or the server restarted. Drop the cached `Mcp-Session-Id` and start a new `initialize` handshake; most clients do this automatically. +- **`404 Session not found`** (JSON-RPC code `-32001`): the session hit the 15-minute idle timeout or the server restarted. The stdio bridge starts a new session on its own and replays the call, so you should never see this through the bridge. If you speak HTTP to the server directly, drop the cached `Mcp-Session-Id` and send a new `initialize` request yourself. Most HTTP clients do not do this for you. - **`429 Too Many Requests`**: 5 failed auth attempts within 60 seconds against the same `(client_address, principal)` pair triggered a 5-minute lockout. Wait it out or restart TablePro. ## Troubleshooting @@ -167,6 +167,8 @@ If the call fails, the response tells you which layer rejected it: **Setapp or non-default install path.** Replace `/Applications/TablePro.app` in the `command` with your install path. For Setapp the bundle lives under `~/Applications/Setapp/TablePro.app`. -**Port conflict.** If the configured port (default 23508) is taken, TablePro falls back to a free port in the 51000-52000 range and rewrites the handshake file. The stdio bridge re-reads it automatically. +**Port conflict.** If the configured port (default 23508) is taken, TablePro falls back to a free port in the 51000-52000 range and rewrites the handshake file. A bridge that is already running re-reads the file the next time a call fails against the old port, so it picks up the new one without a restart. + +**Sessions recover on their own.** The MCP server drops a session after 15 minutes with no client attached, and restarting the server from Settings retires every session and rotates the bridge token. In both cases the stdio bridge starts a fresh session and replays the call that triggered it. You do not need to restart your agent or toggle the server. While an agent is attached, the bridge pings every 5 minutes, so an idle session stays alive rather than being recreated. **Tool calls return `403 Connection is read-only for external clients`.** The connection's external access is `readOnly` and the SQL is a write. Change external access in TablePro, or run the query in TablePro's editor.