From a93f0c020ee51b3f3af391b7767d6b06d638c6d9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 22:25:50 +0700 Subject: [PATCH 1/2] feat(cli): run queries from the shell with tablepro query --- .../CommandLine/CLIArguments.swift | 239 +++++++++++++ .../TableProMCP/CommandLine/CLIFailure.swift | 58 ++++ .../TableProMCP/CommandLine/CLIPairing.swift | 253 ++++++++++++++ .../CommandLine/CLIRowWriter.swift | 259 ++++++++++++++ .../TableProMCP/CommandLine/CLIRunner.swift | 328 ++++++++++++++++++ .../CommandLine/CLIServerClient.swift | 179 ++++++++++ .../CommandLine/CLITokenStore.swift | 112 ++++++ .../CommandLine/CLIArgumentsTests.swift | 95 +++++ .../CommandLine/CLIFailureTests.swift | 36 ++ .../CommandLine/CLIPairingTests.swift | 76 ++++ .../CommandLine/CLIRowWriterTests.swift | 133 +++++++ .../CommandLine/CLIRunnerTests.swift | 314 +++++++++++++++++ .../CommandLine/CLITokenStoreTests.swift | 35 ++ apple/TablePro/CLI/CLIMain.swift | 73 ++++ apple/TablePro/CLI/tablepro | 11 + apple/TablePro/Core/MCP/MCPAuthPolicy.swift | 28 +- .../Protocol/Handlers/ToolsCallHandler.swift | 3 +- .../MCP/Protocol/Tools/ExecuteQueryTool.swift | 9 +- .../MCP/Protocol/Tools/MCPStatementGate.swift | 8 + .../Protocol/Tools/ToolQueryExecutor.swift | 30 +- .../Models/Query/QueryHistorySource.swift | 17 +- .../TablePro/Resources/Localizable.xcstrings | 6 + .../Core/MCP/CLIPairingContractTests.swift | 55 +++ .../Core/MCP/MCPCommandLineHistoryTests.swift | 78 +++++ .../Tools/ExecuteQueryReadOnlyTests.swift | 111 ++++++ .../Tools/ExecuteQueryToolTests.swift | 12 + .../Protocol/Tools/MCPToolTestHarness.swift | 10 +- .../Models/QueryHistorySourceTests.swift | 11 + apple/project.yml | 29 ++ 29 files changed, 2578 insertions(+), 30 deletions(-) create mode 100644 apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIArguments.swift create mode 100644 apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIFailure.swift create mode 100644 apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIPairing.swift create mode 100644 apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIRowWriter.swift create mode 100644 apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIRunner.swift create mode 100644 apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIServerClient.swift create mode 100644 apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLITokenStore.swift create mode 100644 apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIArgumentsTests.swift create mode 100644 apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIFailureTests.swift create mode 100644 apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIPairingTests.swift create mode 100644 apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIRowWriterTests.swift create mode 100644 apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIRunnerTests.swift create mode 100644 apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLITokenStoreTests.swift create mode 100644 apple/TablePro/CLI/CLIMain.swift create mode 100644 apple/TableProTests/Core/MCP/CLIPairingContractTests.swift create mode 100644 apple/TableProTests/Core/MCP/MCPCommandLineHistoryTests.swift create mode 100644 apple/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryReadOnlyTests.swift diff --git a/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIArguments.swift b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIArguments.swift new file mode 100644 index 0000000000..e32c7675fa --- /dev/null +++ b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIArguments.swift @@ -0,0 +1,239 @@ +import Foundation + +public enum CLIOutputFormat: String, Sendable, Equatable, CaseIterable { + case csv + case tsv + case json + case jsonl + case table +} + +public enum CLISqlSource: Sendable, Equatable { + case argument(String) + case file(String) + case standardInput +} + +public struct CLIQueryOptions: Sendable, Equatable { + public var connection: String + public var database: String? + public var schema: String? + public var format: CLIOutputFormat? + public var limit: Int? + public var timeoutSeconds: Int? + public var readOnly: Bool + public var sql: CLISqlSource +} + +public enum CLICommand: Sendable, Equatable { + case query(CLIQueryOptions) + case connections(format: CLIOutputFormat?) + case help(String) +} + +public enum CLIArguments { + public static let queryUsage = """ + Usage: tablepro query --connection [options] [SQL] + + Runs one statement through TablePro and prints the rows. The SQL comes from the + argument, from -f , or from standard input. + + Options: + -c, --connection Saved connection to run against (required) + --database Database to run against + --schema Schema to run against + --format csv, tsv, json, jsonl or table + (table on a terminal, csv otherwise) + --limit Rows to return, up to the maximum in Settings > MCP + --timeout Statement timeout, 1 to 300 + --read-only Refuse any statement that is not a read + -f, --file Read the SQL from a file, - for standard input + -h, --help Show this help + + Exit status: 0 success, 1 query error, 2 usage error, 3 refused by policy, 4 timed out. + """ + + public static let connectionsUsage = """ + Usage: tablepro connections [--format ] + + Lists the saved connections the tablepro command can reach: name, id and type. + + Options: + --format csv, tsv, json, jsonl or table (table on a terminal, csv otherwise) + -h, --help Show this help + """ + + public static func parse(_ arguments: [String]) throws -> CLICommand { + guard let subcommand = arguments.first else { + throw CLIFailure.usage("Name a command: query or connections.") + } + let rest = Array(arguments.dropFirst()) + switch subcommand { + case "query": + return try parseQuery(rest) + case "connections": + return try parseConnections(rest) + default: + throw CLIFailure.usage("Unknown command \"\(subcommand)\". Use query or connections.") + } + } + + private static func parseQuery(_ arguments: [String]) throws -> CLICommand { + var reader = OptionReader(arguments) + var connection: String? + var database: String? + var schema: String? + var format: CLIOutputFormat? + var limit: Int? + var timeout: Int? + var readOnly = false + var file: String? + var positionals: [String] = [] + + while let token = reader.next() { + switch token { + case .option(let name, let inline): + switch name { + case "-h", "--help": + return .help(queryUsage) + case "-c", "--connection": + connection = try reader.value(for: name, inline: inline) + case "--database": + database = try reader.value(for: name, inline: inline) + case "--schema": + schema = try reader.value(for: name, inline: inline) + case "--format": + format = try parseFormat(reader.value(for: name, inline: inline)) + case "--limit": + limit = try parsePositive(reader.value(for: name, inline: inline), option: name) + case "--timeout": + timeout = try parsePositive(reader.value(for: name, inline: inline), option: name) + case "--read-only": + guard inline == nil else { throw CLIFailure.usage("--read-only takes no value.") } + readOnly = true + case "-f", "--file": + file = try reader.value(for: name, inline: inline) + default: + throw CLIFailure.usage("Unknown option \(name). Run tablepro query --help.") + } + case .positional(let value): + positionals.append(value) + } + } + + guard let connection, !connection.isEmpty else { + throw CLIFailure.usage("Pass --connection with a connection's name or id.") + } + guard positionals.count <= 1 else { + throw CLIFailure.usage("Pass the SQL as one quoted argument.") + } + + let sql: CLISqlSource + switch (positionals.first, file) { + case (.some, .some): + throw CLIFailure.usage("Pass the SQL as an argument or with --file, not both.") + case (.some(let text), nil): + sql = .argument(text) + case (nil, .some(let path)): + sql = path == "-" ? .standardInput : .file(path) + case (nil, nil): + sql = .standardInput + } + + return .query(CLIQueryOptions( + connection: connection, + database: database, + schema: schema, + format: format, + limit: limit, + timeoutSeconds: timeout, + readOnly: readOnly, + sql: sql + )) + } + + private static func parseConnections(_ arguments: [String]) throws -> CLICommand { + var reader = OptionReader(arguments) + var format: CLIOutputFormat? + while let token = reader.next() { + switch token { + case .option(let name, let inline): + switch name { + case "-h", "--help": + return .help(connectionsUsage) + case "--format": + format = try parseFormat(reader.value(for: name, inline: inline)) + default: + throw CLIFailure.usage("Unknown option \(name). Run tablepro connections --help.") + } + case .positional(let value): + throw CLIFailure.usage("Unexpected argument \"\(value)\".") + } + } + return .connections(format: format) + } + + private static func parseFormat(_ value: String) throws -> CLIOutputFormat { + guard let format = CLIOutputFormat(rawValue: value.lowercased()) else { + let names = CLIOutputFormat.allCases.map(\.rawValue).joined(separator: ", ") + throw CLIFailure.usage("Unknown format \"\(value)\". Use one of \(names).") + } + return format + } + + private static func parsePositive(_ value: String, option: String) throws -> Int { + guard let number = Int(value), number > 0 else { + throw CLIFailure.usage("\(option) takes a whole number above 0.") + } + return number + } +} + +private struct OptionReader { + enum Token { + case option(name: String, inline: String?) + case positional(String) + } + + private let arguments: [String] + private var index = 0 + private var optionsEnded = false + + init(_ arguments: [String]) { + self.arguments = arguments + } + + mutating func next() -> Token? { + guard index < arguments.count else { return nil } + let argument = arguments[index] + index += 1 + // An option name never holds whitespace, so "-- note\nSELECT 1" is SQL. + let name = argument.split(separator: "=", maxSplits: 1).first ?? "" + if optionsEnded || argument == "-" || !argument.hasPrefix("-") || name.contains(where: \.isWhitespace) { + return .positional(argument) + } + if argument == "--" { + optionsEnded = true + return next() + } + if argument.hasPrefix("--"), let equals = argument.firstIndex(of: "=") { + return .option( + name: String(argument[.. String { + if let inline { + return inline + } + guard index < arguments.count else { + throw CLIFailure.usage("\(option) needs a value.") + } + let value = arguments[index] + index += 1 + return value + } +} diff --git a/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIFailure.swift b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIFailure.swift new file mode 100644 index 0000000000..069624f2c9 --- /dev/null +++ b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIFailure.swift @@ -0,0 +1,58 @@ +import Foundation + +public enum CLIExitCode: Int32, Sendable, Equatable { + case success = 0 + case failure = 1 + case usage = 2 + case refused = 3 + case timedOut = 4 +} + +public struct CLIFailure: Error, Sendable, Equatable { + public let exitCode: CLIExitCode + public let message: String + + public init(_ exitCode: CLIExitCode, _ message: String) { + self.exitCode = exitCode + self.message = message + } + + public static func usage(_ message: String) -> CLIFailure { + CLIFailure(.usage, message) + } + + // A tool error's text is `: ` (developers/mcp-protocol.mdx, Tool errors). + public static func fromToolError(text: String) -> CLIFailure { + guard let separator = text.range(of: ": ") else { + return CLIFailure(.failure, text) + } + let code = String(text[.. CLIFailure { + switch code { + case JsonRpcErrorCode.forbidden, JsonRpcErrorCode.serverDisabled, + JsonRpcErrorCode.unauthenticated, JsonRpcErrorCode.expired: + return CLIFailure(.refused, message) + case JsonRpcErrorCode.requestTimeout: + return CLIFailure(.timedOut, message) + case JsonRpcErrorCode.invalidParams: + return CLIFailure(.usage, message) + default: + return CLIFailure(.failure, message) + } + } +} diff --git a/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIPairing.swift b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIPairing.swift new file mode 100644 index 0000000000..1e4f87b34c --- /dev/null +++ b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIPairing.swift @@ -0,0 +1,253 @@ +import CryptoKit +import Foundation +import Network + +// The loopback pairing flow in developers/pairing.mdx. +public struct CLIPairingRequest: Sendable, Equatable { + public static let callbackPath = "/tablepro-pairing" + + public let verifier: String + public let challenge: String + public let state: String + + public init(verifier: String, state: String) { + self.verifier = verifier + self.challenge = Self.base64Url(Data(SHA256.hash(data: Data(verifier.utf8)))) + self.state = state + } + + public static func random() -> CLIPairingRequest { + CLIPairingRequest(verifier: base64Url(randomBytes(32)), state: base64Url(randomBytes(16))) + } + + public func link(callbackPort: UInt16) -> URL? { + var redirect = URLComponents() + redirect.scheme = "http" + redirect.host = "127.0.0.1" + redirect.port = Int(callbackPort) + redirect.path = Self.callbackPath + guard let redirectUrl = redirect.url else { return nil } + + var components = URLComponents() + components.scheme = "tablepro" + components.host = "integrations" + components.path = "/pair" + components.queryItems = [ + URLQueryItem(name: "client", value: CLIClientIdentity.pairingName), + URLQueryItem(name: "challenge", value: challenge), + URLQueryItem(name: "redirect", value: redirectUrl.absoluteString), + URLQueryItem(name: "response_mode", value: "query"), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "scopes", value: "readOnly") + ] + return components.url + } + + static func base64Url(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private static func randomBytes(_ count: Int) -> Data { + var generator = SystemRandomNumberGenerator() + return Data((0.. CLIPairingCallback { + guard let components = URLComponents(string: requestTarget), + components.path == CLIPairingRequest.callbackPath else { + return .ignored + } + let items = components.queryItems ?? [] + func value(_ name: String) -> String? { + items.first { $0.name == name }?.value + } + guard value("state") == state else { return .ignored } + if let code = value("code"), !code.isEmpty { + return .approved(code: code) + } + return value("error") == nil ? .ignored : .denied + } +} + +public final class CLICallbackListener: @unchecked Sendable { + private static let maximumHeadBytes = 16 * 1_024 + + private let state: String + private let queue = DispatchQueue(label: "com.TablePro.cli.pairing") + private let lock = NSLock() + private var listener: NWListener? + private var connections: [NWConnection] = [] + private var outcome: CLIPairingCallback? + private var waiter: CheckedContinuation? + + public init(state: String) { + self.state = state + } + + public func start() async throws -> UInt16 { + let parameters = NWParameters.tcp + parameters.requiredInterfaceType = .loopback + parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: .any) + let listener = try NWListener(using: parameters) + lock.withLock { self.listener = listener } + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let resumed = ResumeOnce() + listener.stateUpdateHandler = { @Sendable state in + switch state { + case .ready: + guard resumed.claim() else { return } + continuation.resume(returning: listener.port?.rawValue ?? 0) + case .failed(let error): + guard resumed.claim() else { return } + continuation.resume(throwing: error) + default: + break + } + } + listener.newConnectionHandler = { @Sendable [weak self] connection in + self?.accept(connection) + } + listener.start(queue: queue) + } + } + + // Nil when nothing answered in time. + public func waitForCallback(timeout: Duration) async -> CLIPairingCallback? { + let timer = Task { [weak self] in + try? await Task.sleep(for: timeout) + guard !Task.isCancelled else { return } + self?.finish(.ignored) + } + defer { timer.cancel() } + let result = await withCheckedContinuation { (continuation: CheckedContinuation) in + let ready: CLIPairingCallback? = lock.withLock { + if let outcome { return outcome } + waiter = continuation + return nil + } + if let ready { + continuation.resume(returning: ready) + } + } + return result == .ignored ? nil : result + } + + public func stop() { + let (listener, connections) = lock.withLock { () -> (NWListener?, [NWConnection]) in + let taken = (self.listener, self.connections) + self.listener = nil + self.connections = [] + return taken + } + listener?.cancel() + connections.forEach { $0.cancel() } + } + + private func finish(_ result: CLIPairingCallback) { + let waiting: CheckedContinuation? = lock.withLock { + guard outcome == nil else { return nil } + outcome = result + let taken = waiter + waiter = nil + return taken + } + waiting?.resume(returning: result) + } + + private func accept(_ connection: NWConnection) { + lock.withLock { connections.append(connection) } + connection.start(queue: queue) + receive(on: connection, buffer: Data()) + } + + private func receive(on connection: NWConnection, buffer: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 4_096) { @Sendable [weak self] data, _, isComplete, error in + guard let self else { return } + var accumulated = buffer + if let data { + accumulated.append(data) + } + if let head = Self.requestHead(accumulated) { + self.answer(head, on: connection) + return + } + guard error == nil, !isComplete, accumulated.count < Self.maximumHeadBytes else { + connection.cancel() + return + } + self.receive(on: connection, buffer: accumulated) + } + } + + private func answer(_ head: String, on connection: NWConnection) { + let parts = head.split(separator: " ", maxSplits: 2) + let callback: CLIPairingCallback + if parts.count >= 2, parts[0] == "GET" { + callback = CLIPairingCallback.parse(requestTarget: String(parts[1]), state: state) + } else { + callback = .ignored + } + let response = Self.response(for: callback) + connection.send(content: response, completion: .contentProcessed { @Sendable _ in + connection.cancel() + }) + guard callback != .ignored else { return } + finish(callback) + } + + static func requestHead(_ data: Data) -> String? { + guard let end = data.range(of: Data("\r\n".utf8)) else { return nil } + return String(data: data[data.startIndex.. Data { + let status: String + let message: String + switch callback { + case .approved: + status = "200 OK" + message = "TablePro paired the tablepro command. You can close this tab." + case .denied: + status = "200 OK" + message = "Pairing was declined. You can close this tab." + case .ignored: + status = "404 Not Found" + message = "Nothing here." + } + let body = "TablePro

\(message)

\n" + let head = [ + "HTTP/1.1 \(status)", + "Content-Type: text/html; charset=utf-8", + "Content-Length: \(body.utf8.count)", + "Cache-Control: no-store", + "Connection: close", + "", + "" + ].joined(separator: "\r\n") + return Data((head + body).utf8) + } +} + +private final class ResumeOnce: @unchecked Sendable { + private let lock = NSLock() + private var claimed = false + + func claim() -> Bool { + lock.withLock { + guard !claimed else { return false } + claimed = true + return true + } + } +} diff --git a/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIRowWriter.swift b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIRowWriter.swift new file mode 100644 index 0000000000..0ea529431f --- /dev/null +++ b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIRowWriter.swift @@ -0,0 +1,259 @@ +import Foundation + +public struct CLIResultSet: Sendable, Equatable { + public let columns: [String] + public let rows: [[JsonValue]] + + public init(columns: [String], rows: [[JsonValue]]) { + self.columns = columns + self.rows = rows + } +} + +public enum CLIRowWriter { + // JSON nests one array per result set so the output stays one document. + public static func write(_ sets: [CLIResultSet], as format: CLIOutputFormat, to output: inout some TextOutputStream) { + let printable = sets.filter { !$0.columns.isEmpty } + guard printable.count > 1 else { + if let only = printable.first { + write(only, as: format, to: &output) + } + return + } + switch format { + case .json: + output.write("[\n") + for (index, set) in printable.enumerated() { + writeJsonArray(set, indent: " ", last: index == printable.count - 1, to: &output) + } + output.write("]\n") + case .jsonl: + for set in printable { + write(set, as: format, to: &output) + } + case .csv, .tsv, .table: + for (index, set) in printable.enumerated() { + if index > 0 { + output.write("\n") + } + write(set, as: format, to: &output) + } + } + } + + public static func write(_ set: CLIResultSet, as format: CLIOutputFormat, to output: inout some TextOutputStream) { + guard !set.columns.isEmpty else { return } + switch format { + case .csv: + writeDelimited(set, separator: ",", to: &output, field: csvField) + case .tsv: + writeDelimited(set, separator: "\t", to: &output, field: tsvField) + case .json: + writeJsonArray(set, indent: "", last: true, to: &output) + case .jsonl: + let keys = uniqueKeys(set.columns) + for row in set.rows { + output.write(jsonObject(keys: keys, row: row)) + output.write("\n") + } + case .table: + writeTable(set, to: &output) + } + } + + private static func writeDelimited( + _ set: CLIResultSet, + separator: String, + to output: inout some TextOutputStream, + field: (JsonValue) -> String + ) { + output.write(set.columns.map { field(.string($0)) }.joined(separator: separator)) + output.write("\n") + for row in set.rows { + output.write(cells(row, count: set.columns.count).map(field).joined(separator: separator)) + output.write("\n") + } + } + + // NULL is an empty field and an empty string is "", so the two stay apart. + static func csvField(_ value: JsonValue) -> String { + guard let text = text(of: value) else { return "" } + let needsQuotes = text.isEmpty || text.contains(where: { $0 == "," || $0 == "\"" || $0.isNewline }) + guard needsQuotes else { return text } + return "\"" + text.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } + + static func tsvField(_ value: JsonValue) -> String { + guard let text = text(of: value) else { return "" } + var escaped = "" + escaped.reserveCapacity(text.utf8.count) + for scalar in text.unicodeScalars { + switch scalar { + case "\\": escaped += "\\\\" + case "\t": escaped += "\\t" + case "\n": escaped += "\\n" + case "\r": escaped += "\\r" + default: escaped.unicodeScalars.append(scalar) + } + } + return escaped + } + + private static func writeJsonArray( + _ set: CLIResultSet, + indent: String, + last: Bool, + to output: inout some TextOutputStream + ) { + let close = last ? "]\n" : "],\n" + guard !set.rows.isEmpty else { + output.write(indent + "[" + close) + return + } + let keys = uniqueKeys(set.columns) + output.write(indent + "[\n") + for (index, row) in set.rows.enumerated() { + output.write(indent + " ") + output.write(jsonObject(keys: keys, row: row)) + output.write(index == set.rows.count - 1 ? "\n" : ",\n") + } + output.write(indent + close) + } + + static func jsonObject(keys: [String], row: [JsonValue]) -> String { + let values = cells(row, count: keys.count) + let members = zip(keys, values).map { key, value in jsonString(key) + ":" + jsonLiteral(value) } + return "{" + members.joined(separator: ",") + "}" + } + + // `SELECT 1 AS a, 2 AS a` would otherwise give an object whose second key replaces the first. + static func uniqueKeys(_ columns: [String]) -> [String] { + var taken = Set() + return columns.map { column in + var candidate = column + var suffix = 2 + while taken.contains(candidate) { + candidate = "\(column)_\(suffix)" + suffix += 1 + } + taken.insert(candidate) + return candidate + } + } + + private static func jsonLiteral(_ value: JsonValue) -> String { + switch value { + case .null: + return "null" + case .string(let text): + return jsonString(text) + case .bool(let flag): + return flag ? "true" : "false" + case .int(let number): + return String(number) + case .double, .array, .object: + return value.jsonString() + } + } + + static func jsonString(_ text: String) -> String { + var escaped = "\"" + escaped.reserveCapacity(text.utf8.count + 2) + for scalar in text.unicodeScalars { + switch scalar { + case "\"": escaped += "\\\"" + case "\\": escaped += "\\\\" + case "\n": escaped += "\\n" + case "\r": escaped += "\\r" + case "\t": escaped += "\\t" + default: + if scalar.value < 0x20 { + escaped += String(format: "\\u%04x", scalar.value) + } else { + escaped.unicodeScalars.append(scalar) + } + } + } + escaped += "\"" + return escaped + } + + static let nullMarker = "NULL" + + private static func writeTable(_ set: CLIResultSet, to output: inout some TextOutputStream) { + let header = set.columns.map(tableCell) + let body = set.rows.map { cells($0, count: set.columns.count).map(tableCellValue) } + var widths = header.map(\.count) + for row in body { + for (index, cell) in row.enumerated() { + widths[index] = max(widths[index], cell.count) + } + } + output.write(tableLine(header, widths: widths)) + output.write(widths.map { String(repeating: "-", count: $0) }.joined(separator: "-+-")) + output.write("\n") + for row in body { + output.write(tableLine(row, widths: widths)) + } + } + + private static func tableLine(_ cells: [String], widths: [Int]) -> String { + let padded = cells.enumerated().map { index, cell in + index == cells.count - 1 ? cell : cell + String(repeating: " ", count: widths[index] - cell.count) + } + return padded.joined(separator: " | ") + "\n" + } + + private static func tableCellValue(_ value: JsonValue) -> String { + guard let text = text(of: value) else { return nullMarker } + return tableCell(text) + } + + // The table goes to a terminal, so a stored escape sequence is shown, never run. + static func tableCell(_ text: String) -> String { + guard text.unicodeScalars.contains(where: isControl) else { return text } + var escaped = "" + for scalar in text.unicodeScalars { + switch scalar { + case "\\": escaped += "\\\\" + case "\t": escaped += "\\t" + case "\n": escaped += "\\n" + case "\r": escaped += "\\r" + default: + if isControl(scalar) { + escaped += String(format: "\\x%02X", scalar.value) + } else { + escaped.unicodeScalars.append(scalar) + } + } + } + return escaped + } + + private static func isControl(_ scalar: Unicode.Scalar) -> Bool { + scalar.value < 0x20 || (0x7F...0x9F).contains(scalar.value) + } + + private static func cells(_ row: [JsonValue], count: Int) -> [JsonValue] { + guard row.count != count else { return row } + if row.count > count { + return Array(row.prefix(count)) + } + return row + Array(repeating: JsonValue.null, count: count - row.count) + } + + private static func text(of value: JsonValue) -> String? { + switch value { + case .null: + return nil + case .string(let text): + return text + case .bool(let flag): + return flag ? "true" : "false" + case .int(let number): + return String(number) + case .double, .array, .object: + return value.jsonString() + } + } +} diff --git a/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIRunner.swift b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIRunner.swift new file mode 100644 index 0000000000..4ba6e5a733 --- /dev/null +++ b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIRunner.swift @@ -0,0 +1,328 @@ +import Darwin +import Foundation + +public protocol CLIConsole: AnyObject, Sendable { + func out(_ text: String) + func err(_ text: String) +} + +public struct CLITerminal: Sendable, Equatable { + public let standardInput: Bool + public let standardOutput: Bool + public let standardError: Bool + + public init(standardInput: Bool, standardOutput: Bool, standardError: Bool) { + self.standardInput = standardInput + self.standardOutput = standardOutput + self.standardError = standardError + } + + public static var current: CLITerminal { + CLITerminal( + standardInput: isatty(STDIN_FILENO) == 1, + standardOutput: isatty(STDOUT_FILENO) == 1, + standardError: isatty(STDERR_FILENO) == 1 + ) + } +} + +// `appPath` is the bundle of the process serving MCP, which a pairing link has to reach. +public struct CLIServerEndpoint: Sendable, Equatable { + public let port: Int + public let appPath: String? + + public init(port: Int, appPath: String?) { + self.port = port + self.appPath = appPath + } +} + +public struct CLIRunner: Sendable { + public typealias ServerOpener = @Sendable () async throws -> CLIServerEndpoint + public typealias Pairer = @Sendable (_ server: CLIServerEndpoint) async throws -> String + public typealias TextReader = @Sendable () throws -> String + public typealias FileReader = @Sendable (_ path: String) throws -> String + + static let pairingTimeout: Duration = .seconds(330) + + private let console: any CLIConsole + private let terminal: CLITerminal + private let tokenStore: any CLITokenStoring + private let openServer: ServerOpener + private let pair: Pairer + private let readStandardInput: TextReader + private let readFile: FileReader + + public init( + console: any CLIConsole, + terminal: CLITerminal, + tokenStore: any CLITokenStoring, + openServer: @escaping ServerOpener, + pair: @escaping Pairer, + readStandardInput: @escaping TextReader, + readFile: @escaping FileReader + ) { + self.console = console + self.terminal = terminal + self.tokenStore = tokenStore + self.openServer = openServer + self.pair = pair + self.readStandardInput = readStandardInput + self.readFile = readFile + } + + public func run(arguments: [String]) async -> CLIExitCode { + do { + switch try CLIArguments.parse(arguments) { + case .help(let text): + console.out(text + "\n") + case .connections(let format): + try await listConnections(format: format ?? defaultFormat) + case .query(let options): + try await query(options) + } + return .success + } catch let failure as CLIFailure { + console.err("tablepro: \(failure.message)\n") + return failure.exitCode + } catch { + console.err("tablepro: \(error.localizedDescription)\n") + return .failure + } + } + + private var defaultFormat: CLIOutputFormat { + terminal.standardOutput ? .table : .csv + } + + private func listConnections(format: CLIOutputFormat) async throws { + let client = try await connect() + let listing = try await call(client, "list_connections", [:]) + var stream = ConsoleStream(console: console) + CLIRowWriter.write(Self.connectionsSet(listing), as: format, to: &stream) + } + + private func query(_ options: CLIQueryOptions) async throws { + let sql = try readSql(options.sql) + let client = try await connect() + let connectionId = try await resolveConnection(options.connection, client: client) + + var arguments: [String: JsonValue] = [ + "connection_id": .string(connectionId), + "query": .string(sql) + ] + if let database = options.database { arguments["database"] = .string(database) } + if let schema = options.schema { arguments["schema"] = .string(schema) } + if let limit = options.limit { arguments["max_rows"] = .int(limit) } + if let timeout = options.timeoutSeconds { arguments["timeout_seconds"] = .int(timeout) } + if options.readOnly { arguments["read_only"] = .bool(true) } + + let result = try await call(client, "execute_query", arguments) + writeResult(result, format: options.format ?? defaultFormat) + } + + private func writeResult(_ result: JsonValue, format: CLIOutputFormat) { + let entries = result["result_sets"]?.arrayValue ?? [result] + let sets = entries.map(Self.resultSet) + var stream = ConsoleStream(console: console) + CLIRowWriter.write(sets, as: format, to: &stream) + for (entry, set) in zip(entries, sets) where entry["is_truncated"]?.boolValue == true { + let shown = set.rows.count == 1 ? "row" : "\(set.rows.count) rows" + console.err("tablepro: showing the first \(shown). Pass --limit to change it.\n") + } + if sets.allSatisfy({ $0.columns.isEmpty }), let affected = result["rows_affected"]?.intValue { + console.err(affected == 1 ? "1 row affected.\n" : "\(affected) rows affected.\n") + } + } + + private func readSql(_ source: CLISqlSource) throws -> String { + let text: String + switch source { + case .argument(let value): + text = value + case .file(let path): + do { + text = try readFile(path) + } catch { + throw CLIFailure.usage("Cannot read \(path): \(error.localizedDescription)") + } + case .standardInput: + guard !terminal.standardInput else { + throw CLIFailure.usage("Pass the SQL as an argument, with --file, or on standard input.") + } + text = try readStandardInput() + } + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CLIFailure.usage("The SQL is empty.") + } + return text + } + + private func connect() async throws -> CLIServerClient { + let server = try await openServer() + if let token = try tokenStore.read() { + return CLIServerClient(port: server.port, token: token) + } + guard terminal.standardError else { + throw CLIFailure( + .refused, + "The tablepro command is not paired with TablePro yet. Run it once in Terminal to pair it." + ) + } + console.err("tablepro: approve the tablepro command in TablePro to pair it.\n") + let token = try await pair(server) + try tokenStore.save(token) + return CLIServerClient(port: server.port, token: token) + } + + private func call(_ client: CLIServerClient, _ tool: String, _ arguments: [String: JsonValue]) async throws -> JsonValue { + do { + return try await client.callTool(tool, arguments: arguments) + } catch CLIServerClientError.tokenRejected { + tokenStore.delete() + throw CLIFailure( + .refused, + "TablePro no longer accepts this command's token: it was revoked or has expired. Run the command again to pair it." + ) + } + } + + private func resolveConnection(_ value: String, client: CLIServerClient) async throws -> String { + if let id = UUID(uuidString: value) { + return id.uuidString + } + let listing = try await call(client, "list_connections", [:]) + let entries = listing["connections"]?.arrayValue ?? [] + let exact = entries.filter { $0["name"]?.stringValue == value } + let matches = exact.isEmpty + ? entries.filter { $0["name"]?.stringValue?.caseInsensitiveCompare(value) == .orderedSame } + : exact + let ids = matches.compactMap { $0["id"]?.stringValue } + guard let first = ids.first else { + throw CLIFailure.usage( + "No connection named \"\(value)\" is open to the tablepro command. Run tablepro connections to list them." + ) + } + guard ids.count == 1 else { + throw CLIFailure.usage( + "\(ids.count) connections are named \"\(value)\". Pass one of their ids: \(ids.joined(separator: ", "))." + ) + } + return first + } + + static func resultSet(_ value: JsonValue) -> CLIResultSet { + CLIResultSet( + columns: value["columns"]?.arrayValue?.compactMap(\.stringValue) ?? [], + rows: value["rows"]?.arrayValue?.map { $0.arrayValue ?? [] } ?? [] + ) + } + + static func connectionsSet(_ listing: JsonValue) -> CLIResultSet { + let rows = (listing["connections"]?.arrayValue ?? []).map { entry in + [entry["name"] ?? .null, entry["id"] ?? .null, entry["type"] ?? .null] + } + return CLIResultSet(columns: ["name", "id", "type"], rows: rows) + } +} + +private struct ConsoleStream: TextOutputStream { + let console: any CLIConsole + + mutating func write(_ string: String) { + console.out(string) + } +} + +public extension CLIRunner { + static func live(console: any CLIConsole) -> CLIRunner { + CLIRunner( + console: console, + terminal: .current, + tokenStore: CLITokenStores.current(), + openServer: { try await openLiveServer() }, + pair: { server in try await pairLive(server: server) }, + readStandardInput: { + let data = FileHandle.standardInput.readDataToEndOfFile() + guard let text = String(data: data, encoding: .utf8) else { + throw CLIFailure.usage("Standard input is not UTF-8 text.") + } + return text + }, + readFile: { path in try String(contentsOfFile: path, encoding: .utf8) } + ) + } + + // The server must prove it wrote the handshake before any token goes to its port. + private static func openLiveServer() async throws -> CLIServerEndpoint { + let logger = MCPOSBridgeLogger(category: "CLI") + let handshake: MCPHandshakePayload + do { + handshake = try await MCPHandshakeAcquirer(logger: logger).acquire() + } catch MCPHandshakeAcquisitionError.serverTurnedOff { + throw CLIFailure( + .refused, + "TablePro's MCP server is turned off, and the tablepro command reaches TablePro through it. Turn it on under Settings > MCP." + ) + } catch { + throw CLIFailure(.failure, "TablePro did not start its MCP server: \(error.localizedDescription)") + } + guard let credentials = handshake.credentials() else { + throw CLIFailure(.failure, "TablePro's MCP handshake names no usable port.") + } + do { + _ = try await BridgeUpstreamHandshake.verifiedDiscovery( + handshake: handshake, + credentials: credentials, + logger: logger + ) + } catch { + throw CLIFailure(.failure, "The local MCP server did not prove it is TablePro: \(error.localizedDescription)") + } + let appPath = MCPHandshakeTrust.executablePath(forPid: handshake.pid) + .flatMap(MCPHandshakeTrust.appBundleRoot(forExecutable:)) + return CLIServerEndpoint(port: handshake.port, appPath: appPath) + } + + private static func pairLive(server: CLIServerEndpoint) async throws -> String { + let request = CLIPairingRequest.random() + let listener = CLICallbackListener(state: request.state) + defer { listener.stop() } + + let callbackPort: UInt16 + do { + callbackPort = try await listener.start() + } catch { + throw CLIFailure(.failure, "Could not listen for the pairing answer: \(error.localizedDescription)") + } + guard let link = request.link(callbackPort: callbackPort) else { + throw CLIFailure(.failure, "Could not build the pairing link.") + } + try openLink(link, in: server.appPath) + + switch await listener.waitForCallback(timeout: pairingTimeout) { + case .approved(let code): + return try await CLIServerClient.exchange(code: code, verifier: request.verifier, port: server.port) + case .denied: + throw CLIFailure(.refused, "Pairing was declined in TablePro.") + case .ignored, nil: + throw CLIFailure(.refused, "Pairing was not approved in time.") + } + } + + // The code is only good on the instance serving MCP, not on whichever copy owns `tablepro://`. + private static func openLink(_ link: URL, in appPath: String?) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = (appPath.map { ["-a", $0] } ?? []) + [link.absoluteString] + do { + try process.run() + } catch { + throw CLIFailure(.failure, "Could not open TablePro to pair: \(error.localizedDescription)") + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw CLIFailure(.failure, "Could not open TablePro to pair (open exited \(process.terminationStatus)).") + } + } +} diff --git a/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIServerClient.swift b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIServerClient.swift new file mode 100644 index 0000000000..c9b4c6a281 --- /dev/null +++ b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLIServerClient.swift @@ -0,0 +1,179 @@ +import Foundation + +public enum CLIClientIdentity { + // The app files a query under Command Line when a request names this client. + public static let name = "tablepro-cli" + public static let title = "Command Line" + public static let pairingName = "tablepro command" +} + +public enum CLIServerClientError: Error, Sendable, Equatable { + // Revoked, expired or unknown. + case tokenRejected(String) +} + +public struct CLIServerClient: Sendable { + static let requestTimeout: TimeInterval = 360 + + private let port: Int + private let token: String + private let session: URLSession + + public init(port: Int, token: String, session: URLSession = CLIServerClient.makeSession()) { + self.port = port + self.token = token + self.session = session + } + + public static func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = requestTimeout + configuration.waitsForConnectivity = false + configuration.connectionProxyDictionary = [:] + return URLSession(configuration: configuration) + } + + public func callTool(_ name: String, arguments: [String: JsonValue]) async throws -> JsonValue { + // The server tracks a call in flight by token and id, and every run of this command shares one token. + let requestId = JsonRpcId.string(UUID().uuidString) + let params: JsonValue = .object([ + "name": .string(name), + "arguments": .object(arguments), + "_meta": .object([ + MCPMetaKeys.protocolVersion: .string(MCPProtocolVersion.latest.rawValue), + MCPMetaKeys.clientCapabilities: .object([:]), + MCPMetaKeys.clientInfo: .object([ + "name": .string(CLIClientIdentity.name), + "title": .string(CLIClientIdentity.title), + "version": .string("1") + ]) + ]) + ]) + let body = try JsonRpcCodec.encode(.request(JsonRpcRequest(id: requestId, method: "tools/call", params: params))) + + var request = URLRequest(url: endpoint(path: "/mcp")) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = Self.requestTimeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue(MCPProtocolVersion.latest.rawValue, forHTTPHeaderField: "MCP-Protocol-Version") + request.setValue("tools/call", forHTTPHeaderField: "Mcp-Method") + request.setValue(MCPBase64Sentinel.encodeIfNeeded(name), forHTTPHeaderField: "Mcp-Name") + + let (data, response) = try await send(request) + let message = try await responseMessage(data: data, response: response, requestId: requestId) + switch message { + case .successResponse(let success): + return try Self.toolOutput(success.result) + case .errorResponse(let failure): + if failure.error.code == JsonRpcErrorCode.unauthenticated || failure.error.code == JsonRpcErrorCode.expired { + throw CLIServerClientError.tokenRejected(failure.error.message) + } + throw CLIFailure.fromRpcError(code: failure.error.code, message: failure.error.message) + case .request, .notification: + throw CLIFailure(.failure, "TablePro answered with something that is not a response.") + } + } + + static func toolOutput(_ result: JsonValue) throws -> JsonValue { + let text = result["content"]?.arrayValue?.first?["text"]?.stringValue + if result["isError"]?.boolValue == true { + throw CLIFailure.fromToolError(text: text ?? "internal_failure: The tool failed without a message.") + } + if let structured = result["structuredContent"] { + return structured + } + guard let text, let decoded = try? JSONDecoder().decode(JsonValue.self, from: Data(text.utf8)) else { + throw CLIFailure(.failure, "TablePro returned a result this command cannot read.") + } + return decoded + } + + private func responseMessage(data: Data, response: HTTPURLResponse, requestId: JsonRpcId) async throws -> JsonRpcMessage { + let contentType = response.value(forHTTPHeaderField: "Content-Type")?.lowercased() ?? "" + if contentType.contains("text/event-stream") { + let decoder = SseDecoder() + for frame in await decoder.feed(data) { + guard let message = try? JsonRpcCodec.decode(Data(frame.data.utf8)) else { continue } + if Self.answers(message, requestId: requestId) { + return message + } + } + throw CLIFailure(.failure, "TablePro closed the stream before it answered.") + } + if let message = try? JsonRpcCodec.decode(data) { + return message + } + if response.statusCode == 401 { + throw CLIServerClientError.tokenRejected("HTTP 401") + } + throw CLIFailure(.failure, "TablePro's MCP server answered HTTP \(response.statusCode).") + } + + private static func answers(_ message: JsonRpcMessage, requestId: JsonRpcId) -> Bool { + switch message { + case .successResponse(let success): + return success.id == requestId + case .errorResponse(let failure): + return failure.id == requestId || failure.id == nil + case .request, .notification: + return false + } + } + + // The exchange takes no bearer token: the one-time code and the verifier are the credential. + public static func exchange( + code: String, + verifier: String, + port: Int, + session: URLSession = makeSession() + ) async throws -> String { + var request = URLRequest(url: endpoint(port: port, path: "/v1/integrations/exchange")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["code": code, "code_verifier": verifier]) + + let (data, response) = try await send(request, session: session) + let body = try? JSONDecoder().decode(JsonValue.self, from: data) + guard response.statusCode == 200, let token = body?["token"]?.stringValue, !token.isEmpty else { + let detail = body?["error"]?.stringValue ?? "HTTP \(response.statusCode)" + throw CLIFailure(.refused, "TablePro did not hand over a token: \(detail).") + } + return token + } + + private func endpoint(path: String) -> URL { + Self.endpoint(port: port, path: path) + } + + static func endpoint(port: Int, path: String) -> URL { + var components = URLComponents() + components.scheme = "http" + components.host = "127.0.0.1" + components.port = port + components.path = path + return components.url ?? URL(fileURLWithPath: "/") + } + + private func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + try await Self.send(request, session: session) + } + + private static func send(_ request: URLRequest, session: URLSession) async throws -> (Data, HTTPURLResponse) { + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch let error as URLError where error.code == .timedOut { + throw CLIFailure(.timedOut, "TablePro did not answer in time.") + } catch { + throw CLIFailure(.failure, "TablePro's MCP server is not reachable: \(error.localizedDescription)") + } + guard let http = response as? HTTPURLResponse else { + throw CLIFailure(.failure, "TablePro's MCP server did not answer over HTTP.") + } + return (data, http) + } +} diff --git a/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLITokenStore.swift b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLITokenStore.swift new file mode 100644 index 0000000000..caecd760a2 --- /dev/null +++ b/apple/Packages/TableProMacCore/Sources/TableProMCP/CommandLine/CLITokenStore.swift @@ -0,0 +1,112 @@ +import Foundation +import Security + +public protocol CLITokenStoring: Sendable { + func read() throws -> String? + func save(_ token: String) throws + func delete() +} + +public enum CLITokenStores { + // A sandboxed run keeps its token inside the sandbox, as the app keeps its keychain there. + public static func current( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> any CLITokenStoring { + let sandbox = environment[MCPHandshakeLocation.sandboxEnvironmentVariable]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !sandbox.isEmpty else { + return CLIKeychainTokenStore() + } + let directory = MCPHandshakeLocation.supportDirectory(environment: environment) + return CLIFileTokenStore(url: directory.appendingPathComponent(CLIFileTokenStore.fileName, isDirectory: false)) + } +} + +public struct CLIKeychainTokenStore: CLITokenStoring { + static let service = "com.TablePro.cli" + static let account = "default" + + public init() {} + + public func read() throws -> String? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess, let data = item as? Data, let token = String(data: data, encoding: .utf8) else { + throw CLIFailure(.failure, "Could not read this command's token from the Keychain (\(Self.describe(status))).") + } + return token + } + + public func save(_ token: String) throws { + delete() + var attributes = baseQuery + attributes[kSecValueData as String] = Data(token.utf8) + attributes[kSecAttrLabel as String] = "TablePro command line token" + let status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecSuccess else { + throw CLIFailure(.failure, "Could not save this command's token in the Keychain (\(Self.describe(status))).") + } + } + + public func delete() { + SecItemDelete(baseQuery as CFDictionary) + } + + private var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account + ] + } + + private static func describe(_ status: OSStatus) -> String { + (SecCopyErrorMessageString(status, nil) as String?) ?? "status \(status)" + } +} + +public struct CLIFileTokenStore: CLITokenStoring { + static let fileName = "cli-token" + + private let url: URL + + public init(url: URL) { + self.url = url + } + + public func read() throws -> String? { + guard let data = try? Data(contentsOf: url), + let token = String(bytes: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty else { + return nil + } + return token + } + + public func save(_ token: String) throws { + let fileManager = FileManager.default + do { + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + delete() + guard fileManager.createFile( + atPath: url.path, + contents: Data(token.utf8), + attributes: [.posixPermissions: 0o600] + ) else { + throw CocoaError(.fileWriteUnknown) + } + } catch { + throw CLIFailure(.failure, "Could not save this command's token: \(error.localizedDescription)") + } + } + + public func delete() { + try? FileManager.default.removeItem(at: url) + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIArgumentsTests.swift b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIArgumentsTests.swift new file mode 100644 index 0000000000..d672a83d3e --- /dev/null +++ b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIArgumentsTests.swift @@ -0,0 +1,95 @@ +import Foundation +@testable import TableProMCP +import Testing + +struct CLIArgumentsTests { + private func query(_ arguments: [String]) throws -> CLIQueryOptions { + guard case .query(let options) = try CLIArguments.parse(["query"] + arguments) else { + Issue.record("Expected a query command") + throw CLIFailure.usage("not a query") + } + return options + } + + private func usageError(_ arguments: [String]) -> CLIFailure? { + do { + _ = try CLIArguments.parse(arguments) + return nil + } catch let failure as CLIFailure { + return failure + } catch { + return nil + } + } + + @Test("Every query option is read, in both spellings") + func readsEveryOption() throws { + let options = try query([ + "--connection", "Staging", "--database=app", "--schema", "public", "--format", "CSV", + "--limit", "20", "--timeout=5", "--read-only", "SELECT 1 AS n" + ]) + #expect(options == CLIQueryOptions( + connection: "Staging", + database: "app", + schema: "public", + format: .csv, + limit: 20, + timeoutSeconds: 5, + readOnly: true, + sql: .argument("SELECT 1 AS n") + )) + } + + @Test("The SQL comes from the argument, a file, or standard input") + func sqlSources() throws { + #expect(try query(["-c", "x", "SELECT 1"]).sql == .argument("SELECT 1")) + #expect(try query(["-c", "x", "-f", "q.sql"]).sql == .file("q.sql")) + #expect(try query(["-c", "x", "--file", "-"]).sql == .standardInput) + #expect(try query(["-c", "x"]).sql == .standardInput) + } + + @Test("SQL that starts with a comment is SQL, not an option") + func leadingCommentIsSql() throws { + let sql = "-- report\nSELECT 1" + #expect(try query(["-c", "x", sql]).sql == .argument(sql)) + #expect(try query(["-c", "x", "--", "-1"]).sql == .argument("-1")) + } + + @Test("A connection name with a space survives the inline spelling") + func inlineValueWithSpace() throws { + #expect(try query(["--connection=My Prod", "SELECT 1"]).connection == "My Prod") + } + + @Test("Usage mistakes exit 2") + func usageMistakes() { + let cases: [[String]] = [ + [], + ["select"], + ["query", "SELECT 1"], + ["query", "-c", "x", "--format", "xml", "SELECT 1"], + ["query", "-c", "x", "--limit", "0", "SELECT 1"], + ["query", "-c", "x", "--timeout", "soon", "SELECT 1"], + ["query", "-c", "x", "--bogus", "SELECT 1"], + ["query", "-c", "x", "SELECT", "1"], + ["query", "-c", "x", "-f", "q.sql", "SELECT 1"], + ["query", "-c"], + ["query", "-c", "x", "--read-only=yes", "SELECT 1"], + ["connections", "extra"] + ] + for arguments in cases { + #expect(usageError(arguments)?.exitCode == .usage, "\(arguments)") + } + } + + @Test("Help is a command of its own and wins over a missing connection") + func help() throws { + #expect(try CLIArguments.parse(["query", "--help"]) == .help(CLIArguments.queryUsage)) + #expect(try CLIArguments.parse(["connections", "-h"]) == .help(CLIArguments.connectionsUsage)) + } + + @Test("connections takes a format") + func connections() throws { + #expect(try CLIArguments.parse(["connections"]) == .connections(format: nil)) + #expect(try CLIArguments.parse(["connections", "--format", "json"]) == .connections(format: .json)) + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIFailureTests.swift b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIFailureTests.swift new file mode 100644 index 0000000000..cceaeba593 --- /dev/null +++ b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIFailureTests.swift @@ -0,0 +1,36 @@ +import Foundation +@testable import TableProMCP +import Testing + +struct CLIFailureTests { + @Test("Tool error codes map to the documented exit status", arguments: [ + ("denied: This connection is read only for external clients.", CLIExitCode.refused), + ("user_cancelled: The user cancelled this operation.", .refused), + ("timeout: The query timed out after 5 seconds.", .timedOut), + ("invalid_argument: Send one statement at a time.", .usage), + ("query_failed: relation \"user\" does not exist", .failure), + ("not_connected: The connection is not open.", .failure), + ("internal_failure: boom", .failure), + ("no code here", .failure) + ]) + func toolErrors(text: String, expected: CLIExitCode) { + #expect(CLIFailure.fromToolError(text: text).exitCode == expected) + } + + @Test("The message drops the machine-readable code") + func toolErrorMessage() { + #expect(CLIFailure.fromToolError(text: "timeout: took too long").message == "took too long") + } + + @Test("Protocol error codes map to the documented exit status", arguments: [ + (JsonRpcErrorCode.forbidden, CLIExitCode.refused), + (JsonRpcErrorCode.serverDisabled, .refused), + (JsonRpcErrorCode.requestTimeout, .timedOut), + (JsonRpcErrorCode.invalidParams, .usage), + (JsonRpcErrorCode.rateLimited, .failure), + (JsonRpcErrorCode.internalError, .failure) + ]) + func rpcErrors(code: Int, expected: CLIExitCode) { + #expect(CLIFailure.fromRpcError(code: code, message: "m").exitCode == expected) + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIPairingTests.swift b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIPairingTests.swift new file mode 100644 index 0000000000..5bc71b9517 --- /dev/null +++ b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIPairingTests.swift @@ -0,0 +1,76 @@ +import CryptoKit +import Foundation +@testable import TableProMCP +import Testing + +struct CLIPairingTests { + @Test("The verifier and the challenge are the shapes TablePro's validator accepts") + func pkceShapes() { + let request = CLIPairingRequest.random() + let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + #expect((43...128).contains(request.verifier.count)) + #expect(request.verifier.unicodeScalars.allSatisfy { unreserved.contains($0) }) + #expect(request.challenge.count == 43) + let digest = Data(SHA256.hash(data: Data(request.verifier.utf8))) + #expect(request.challenge == CLIPairingRequest.base64Url(digest)) + #expect(CLIPairingRequest.random().verifier != request.verifier) + } + + @Test("The link asks for a read-only token and a loopback callback") + func link() throws { + let request = CLIPairingRequest(verifier: String(repeating: "v", count: 43), state: "s1") + let url = try #require(request.link(callbackPort: 50_123)) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(components.scheme == "tablepro") + #expect(components.host == "integrations") + #expect(components.path == "/pair") + let items = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + #expect(items["client"] == CLIClientIdentity.pairingName) + #expect(items["challenge"] == request.challenge) + #expect(items["redirect"] == "http://127.0.0.1:50123\(CLIPairingRequest.callbackPath)") + #expect(items["response_mode"] == "query") + #expect(items["state"] == "s1") + #expect(items["scopes"] == "readOnly") + } + + @Test("Only the callback path with this pairing's state counts") + func callbackParsing() { + let path = CLIPairingRequest.callbackPath + #expect(CLIPairingCallback.parse(requestTarget: "\(path)?code=abc&state=s1", state: "s1") == .approved(code: "abc")) + #expect(CLIPairingCallback.parse( + requestTarget: "\(path)?error=access_denied&error_description=user_denied&state=s1", + state: "s1" + ) == .denied) + #expect(CLIPairingCallback.parse(requestTarget: "\(path)?code=abc&state=other", state: "s1") == .ignored) + #expect(CLIPairingCallback.parse(requestTarget: "/favicon.ico", state: "s1") == .ignored) + #expect(CLIPairingCallback.parse(requestTarget: "\(path)?state=s1", state: "s1") == .ignored) + } + + @Test("The listener answers stray requests and hands back the code the browser delivers") + func listenerRoundTrip() async throws { + let listener = CLICallbackListener(state: "s1") + defer { listener.stop() } + let port = try await listener.start() + #expect(port > 0) + + let session = URLSession(configuration: .ephemeral) + let stray = try #require(URL(string: "http://127.0.0.1:\(port)/favicon.ico")) + let (_, strayResponse) = try await session.data(from: stray) + #expect((strayResponse as? HTTPURLResponse)?.statusCode == 404) + + let callback = try #require(URL(string: "http://127.0.0.1:\(port)\(CLIPairingRequest.callbackPath)?code=c-1&state=s1")) + let (body, response) = try await session.data(from: callback) + #expect((response as? HTTPURLResponse)?.statusCode == 200) + #expect(String(data: body, encoding: .utf8)?.contains("You can close this tab") == true) + + #expect(await listener.waitForCallback(timeout: .seconds(5)) == .approved(code: "c-1")) + } + + @Test("A listener nobody calls gives up") + func listenerTimeout() async throws { + let listener = CLICallbackListener(state: "s1") + defer { listener.stop() } + _ = try await listener.start() + #expect(await listener.waitForCallback(timeout: .milliseconds(50)) == nil) + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIRowWriterTests.swift b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIRowWriterTests.swift new file mode 100644 index 0000000000..2f3998c412 --- /dev/null +++ b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIRowWriterTests.swift @@ -0,0 +1,133 @@ +import Foundation +@testable import TableProMCP +import Testing + +struct CLIRowWriterTests { + private func render(_ set: CLIResultSet, _ format: CLIOutputFormat) -> String { + var output = "" + CLIRowWriter.write(set, as: format, to: &output) + return output + } + + private let mixed = CLIResultSet( + columns: ["id", "note"], + rows: [ + [.string("1"), .string("plain")], + [.string("2"), .null], + [.string("3"), .string("")], + [.string("4"), .string("a,\"b\"\nc\td\\")] + ] + ) + + @Test("CSV prints a header and one row for SELECT 1 AS n") + func csvSingleRow() { + let set = CLIResultSet(columns: ["n"], rows: [[.string("1")]]) + #expect(render(set, .csv) == "n\n1\n") + } + + @Test("CSV keeps NULL apart from an empty string and quotes what needs it") + func csvQuoting() { + #expect(render(mixed, .csv) == """ + id,note + 1,plain + 2, + 3,"" + 4,"a,""b"" + c\td\\" + + """) + } + + @Test("TSV escapes tabs, newlines and backslashes so every row stays on one line") + func tsvEscaping() { + #expect(render(mixed, .tsv) == "id\tnote\n1\tplain\n2\t\n3\t\n4\ta,\"b\"\\nc\\td\\\\\n") + } + + @Test("JSON is an array of objects in column order, with null for NULL") + func jsonArray() { + let set = CLIResultSet(columns: ["b", "a"], rows: [[.string("1"), .null], [.string("x\"y"), .string("2")]]) + let text = render(set, .json) + #expect(text == "[\n {\"b\":\"1\",\"a\":null},\n {\"b\":\"x\\\"y\",\"a\":\"2\"}\n]\n") + let decoded = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [[String: Any]] + #expect(decoded?.count == 2) + } + + @Test("JSON with no rows is an empty array, JSONL prints nothing") + func emptyResults() { + let set = CLIResultSet(columns: ["n"], rows: []) + #expect(render(set, .json) == "[]\n") + #expect(render(set, .jsonl).isEmpty) + #expect(render(set, .csv) == "n\n") + } + + @Test("JSONL prints one object per line") + func jsonLines() { + let set = CLIResultSet(columns: ["n"], rows: [[.string("1")], [.string("2")]]) + #expect(render(set, .jsonl) == "{\"n\":\"1\"}\n{\"n\":\"2\"}\n") + } + + @Test("Repeated column names get distinct keys instead of overwriting each other") + func duplicateKeys() { + #expect(CLIRowWriter.uniqueKeys(["a", "a", "a_2", "b"]) == ["a", "a_2", "a_2_2", "b"]) + } + + @Test("Control characters are escaped in JSON strings") + func jsonControlCharacters() { + #expect(CLIRowWriter.jsonString("a\u{1}b\nc") == "\"a\\u0001b\\nc\"") + } + + @Test("The table pads every column but the last and shows NULL") + func table() { + let set = CLIResultSet( + columns: ["id", "email"], + rows: [[.string("1"), .string("ada@example.com")], [.string("10"), .null]] + ) + #expect(render(set, .table) == """ + id | email + ---+---------------- + 1 | ada@example.com + 10 | NULL + + """) + } + + @Test("The table shows a stored escape sequence instead of sending it to the terminal") + func tableEscapesControlCharacters() { + #expect(CLIRowWriter.tableCell("\u{1B}[2Jgone") == "\\x1B[2Jgone") + #expect(CLIRowWriter.tableCell("a\u{8}\u{9B}b") == "a\\x08\\x9Bb") + #expect(CLIRowWriter.tableCell("tab\there\\") == "tab\\there\\\\") + #expect(CLIRowWriter.tableCell("plain\\path") == "plain\\path") + let set = CLIResultSet(columns: ["n\u{1B}"], rows: [[.string("\u{7}")]]) + #expect(render(set, .table) == "n\\x1B\n-----\n\\x07\n") + } + + @Test("Several result sets in JSON stay one document") + func jsonResultSets() { + let sets = [ + CLIResultSet(columns: ["a"], rows: [[.string("1")]]), + CLIResultSet(columns: [], rows: []), + CLIResultSet(columns: ["b"], rows: []) + ] + var output = "" + CLIRowWriter.write(sets, as: .json, to: &output) + #expect(output == "[\n [\n {\"a\":\"1\"}\n ],\n []\n]\n") + let decoded = try? JSONSerialization.jsonObject(with: Data(output.utf8)) as? [Any] + #expect(decoded?.count == 2) + } + + @Test("Several result sets in CSV are separated by a blank line") + func csvResultSets() { + let sets = [CLIResultSet(columns: ["a"], rows: [[.string("1")]]), CLIResultSet(columns: ["b"], rows: [[.string("2")]])] + var output = "" + CLIRowWriter.write(sets, as: .csv, to: &output) + #expect(output == "a\n1\n\nb\n2\n") + } + + @Test("A statement with no columns prints nothing in any format") + func noColumns() { + let set = CLIResultSet(columns: [], rows: []) + for format in CLIOutputFormat.allCases { + #expect(render(set, format).isEmpty, "\(format)") + } + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIRunnerTests.swift b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIRunnerTests.swift new file mode 100644 index 0000000000..e76b40624f --- /dev/null +++ b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLIRunnerTests.swift @@ -0,0 +1,314 @@ +import Foundation +@testable import TableProMCP +import TableProMCPTestSupport +import Testing + +private final class RecordingConsole: CLIConsole, @unchecked Sendable { + private let lock = NSLock() + private var standardOutput = "" + private var standardError = "" + + var output: String { lock.withLock { standardOutput } } + var errors: String { lock.withLock { standardError } } + + func out(_ text: String) { + lock.withLock { standardOutput += text } + } + + func err(_ text: String) { + lock.withLock { standardError += text } + } +} + +private final class MemoryTokenStore: CLITokenStoring, @unchecked Sendable { + private let lock = NSLock() + private var token: String? + + init(_ token: String?) { + self.token = token + } + + var current: String? { lock.withLock { token } } + + func read() throws -> String? { current } + + func save(_ token: String) throws { + lock.withLock { self.token = token } + } + + func delete() { + lock.withLock { token = nil } + } +} + +private final class PairingRecorder: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var calls: Int { lock.withLock { count } } + + func pair() -> String { + lock.withLock { count += 1 } + return "tp_paired" + } +} + +private struct Rig { + let server: MockHttpServer + let console = RecordingConsole() + let tokens: MemoryTokenStore + let pairing = PairingRecorder() + + func runner(stdinIsTerminal: Bool = true, stderrIsTerminal: Bool = true, stdin: String = "") -> CLIRunner { + let port = Int(server.port) + let pairing = pairing + return CLIRunner( + console: console, + terminal: CLITerminal(standardInput: stdinIsTerminal, standardOutput: false, standardError: stderrIsTerminal), + tokenStore: tokens, + openServer: { CLIServerEndpoint(port: port, appPath: nil) }, + pair: { _ in pairing.pair() }, + readStandardInput: { stdin }, + readFile: { path in throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: path]) } + ) + } +} + +private func withRig(token: String? = "tp_stored", _ body: (Rig) async throws -> Void) async throws { + let server = MockHttpServer() + try await server.start() + do { + try await body(Rig(server: server, tokens: MemoryTokenStore(token))) + } catch { + await server.stop() + throw error + } + await server.stop() +} + +private func success(_ structured: JsonValue) throws -> MockHttpResponse { + let result: JsonValue = .object([ + "content": .array([.object(["type": .string("text"), "text": .string(structured.jsonString())])]), + "structuredContent": structured, + "isError": .bool(false) + ]) + return try rpc(.successResponse(JsonRpcSuccessResponse(id: .number(1), result: result))) +} + +private func toolError(_ text: String) throws -> MockHttpResponse { + let result: JsonValue = .object([ + "content": .array([.object(["type": .string("text"), "text": .string(text)])]), + "isError": .bool(true) + ]) + return try rpc(.successResponse(JsonRpcSuccessResponse(id: .number(1), result: result))) +} + +private func rpc(_ message: JsonRpcMessage, status: Int = 200) throws -> MockHttpResponse { + MockHttpResponse(status: status, headers: [("Content-Type", "application/json")], body: try JsonRpcCodec.encode(message)) +} + +private func rows(_ columns: [String], _ values: [[JsonValue]], truncated: Bool = false) -> JsonValue { + .object([ + "columns": .array(columns.map { .string($0) }), + "rows": .array(values.map { .array($0) }), + "row_count": .int(values.count), + "rows_affected": .int(0), + "is_truncated": .bool(truncated) + ]) +} + +private func arguments(of request: MockHttpRequest) throws -> JsonValue { + let body = try JSONDecoder().decode(JsonValue.self, from: request.body) + return try #require(body["params"]?["arguments"]) +} + +private let stagingId = "6B9F7C2E-1D3A-4C5B-8E9F-0A1B2C3D4E5F" + +struct CLIRunnerTests { + @Test("SELECT 1 AS n prints a CSV header and one row and exits 0") + func csvQuery() async throws { + try await withRig { rig in + await rig.server.setResponse(try success(rows(["n"], [[.string("1")]]))) + let code = await rig.runner().run(arguments: ["query", "-c", stagingId, "--format", "csv", "SELECT 1 AS n"]) + #expect(code == .success) + #expect(rig.console.output == "n\n1\n") + #expect(rig.console.errors.isEmpty) + + let request = try #require(await rig.server.requests.first) + #expect(request.path == "/mcp") + #expect(request.header("Authorization") == "Bearer tp_stored") + #expect(request.header("Mcp-Method") == "tools/call") + #expect(request.header("Mcp-Name") == "execute_query") + let body = try JSONDecoder().decode(JsonValue.self, from: request.body) + #expect(body["params"]?["_meta"]?[MCPMetaKeys.clientInfo]?["name"]?.stringValue == CLIClientIdentity.name) + let sent = try arguments(of: request) + #expect(sent["connection_id"]?.stringValue == stagingId) + #expect(sent["query"]?.stringValue == "SELECT 1 AS n") + #expect(sent["read_only"] == nil) + } + } + + @Test("--read-only goes to the server, and its refusal exits 3") + func readOnlyRefusal() async throws { + try await withRig { rig in + await rig.server.setResponse(try toolError("denied: This request is read only, and the statement is not a read.")) + let code = await rig.runner().run(arguments: ["query", "-c", stagingId, "--read-only", "DELETE FROM t"]) + #expect(code == .refused) + #expect(rig.console.output.isEmpty) + #expect(rig.console.errors.contains("read only")) + let request = try #require(await rig.server.requests.first) + #expect(try arguments(of: request)["read_only"]?.boolValue == true) + } + } + + @Test("A statement timeout exits 4") + func timeout() async throws { + try await withRig { rig in + await rig.server.setResponse(try toolError("timeout: The query did not finish within 5 seconds.")) + let code = await rig.runner().run(arguments: ["query", "-c", stagingId, "--timeout", "5", "SELECT pg_sleep(10)"]) + #expect(code == .timedOut) + let request = try #require(await rig.server.requests.first) + #expect(try arguments(of: request)["timeout_seconds"]?.intValue == 5) + } + } + + @Test("A revoked token is forgotten and the run exits 3") + func revokedToken() async throws { + try await withRig { rig in + let failure = JsonRpcErrorResponse(id: nil, error: JsonRpcError(code: JsonRpcErrorCode.unauthenticated, message: "Unauthenticated")) + await rig.server.setResponse(try rpc(.errorResponse(failure), status: 401)) + let code = await rig.runner().run(arguments: ["query", "-c", stagingId, "SELECT 1"]) + #expect(code == .refused) + #expect(rig.tokens.current == nil) + #expect(rig.console.errors.contains("revoked")) + #expect(rig.pairing.calls == 0) + } + } + + @Test("A connection name is resolved through list_connections, ignoring case") + func nameResolution() async throws { + try await withRig { rig in + let listing: JsonValue = .object(["connections": .array([ + .object(["id": .string(stagingId), "name": .string("Staging"), "type": .string("PostgreSQL")]), + .object(["id": .string(UUID().uuidString), "name": .string("Production"), "type": .string("MySQL")]) + ])]) + await rig.server.setResponses([try success(listing), try success(rows(["n"], [[.string("1")]]))]) + let code = await rig.runner().run(arguments: ["query", "-c", "staging", "SELECT 1"]) + #expect(code == .success) + let requests = await rig.server.requests + #expect(requests.map { $0.header("Mcp-Name") } == ["list_connections", "execute_query"]) + #expect(try arguments(of: requests[1])["connection_id"]?.stringValue == stagingId) + } + } + + @Test("Two connections with the same name are a usage error that lists their ids") + func ambiguousName() async throws { + try await withRig { rig in + let listing: JsonValue = .object(["connections": .array([ + .object(["id": .string("A"), "name": .string("Dev"), "type": .string("SQLite")]), + .object(["id": .string("B"), "name": .string("Dev"), "type": .string("SQLite")]) + ])]) + await rig.server.setResponse(try success(listing)) + let code = await rig.runner().run(arguments: ["query", "-c", "Dev", "SELECT 1"]) + #expect(code == .usage) + #expect(rig.console.errors.contains("A, B")) + #expect(await rig.server.requests.count == 1) + } + } + + @Test("An unpaired command outside a terminal refuses instead of opening a pairing sheet") + func noPairingWithoutTerminal() async throws { + try await withRig(token: nil) { rig in + let code = await rig.runner(stderrIsTerminal: false).run(arguments: ["query", "-c", stagingId, "SELECT 1"]) + #expect(code == .refused) + #expect(rig.pairing.calls == 0) + #expect(await rig.server.requests.isEmpty) + } + } + + @Test("An unpaired command in a terminal pairs once and keeps the token") + func pairsOnce() async throws { + try await withRig(token: nil) { rig in + await rig.server.setResponse(try success(rows(["n"], [[.string("1")]]))) + let code = await rig.runner().run(arguments: ["query", "-c", stagingId, "SELECT 1"]) + #expect(code == .success) + #expect(rig.pairing.calls == 1) + #expect(rig.tokens.current == "tp_paired") + #expect(await rig.server.requests.first?.header("Authorization") == "Bearer tp_paired") + } + } + + @Test("connections prints name, id and type, and nothing else") + func connections() async throws { + try await withRig { rig in + let listing: JsonValue = .object(["connections": .array([ + .object([ + "id": .string(stagingId), "name": .string("Staging"), "type": .string("PostgreSQL"), + "host": .string("db.internal"), "port": .int(5_432) + ]) + ])]) + await rig.server.setResponse(try success(listing)) + let code = await rig.runner().run(arguments: ["connections", "--format", "json"]) + #expect(code == .success) + #expect(rig.console.output == "[\n {\"name\":\"Staging\",\"id\":\"\(stagingId)\",\"type\":\"PostgreSQL\"}\n]\n") + } + } + + @Test("A cut-short result says so on standard error") + func truncatedNote() async throws { + try await withRig { rig in + await rig.server.setResponse(try success(rows(["n"], [[.string("1")]], truncated: true))) + let code = await rig.runner().run(arguments: ["query", "-c", stagingId, "--limit", "1", "SELECT n FROM t"]) + #expect(code == .success) + #expect(rig.console.errors == "tablepro: showing the first row. Pass --limit to change it.\n") + } + } + + @Test("Every result set of a script is printed") + func resultSets() async throws { + try await withRig { rig in + var primary = rows(["a"], [[.string("1")]]).objectValue ?? [:] + primary["result_sets"] = .array([rows(["a"], [[.string("1")]]), rows(["b"], [[.string("2")]])]) + await rig.server.setResponse(try success(.object(primary))) + let code = await rig.runner().run(arguments: ["query", "-c", stagingId, "--format", "csv", "SELECT 1 AS a\nGO\nSELECT 2 AS b"]) + #expect(code == .success) + #expect(rig.console.output == "a\n1\n\nb\n2\n") + } + } + + @Test("SQL comes from standard input when it is piped") + func standardInput() async throws { + try await withRig { rig in + await rig.server.setResponse(try success(rows(["n"], [[.string("1")]]))) + let code = await rig.runner(stdinIsTerminal: false, stdin: "SELECT 1\n").run(arguments: ["query", "-c", stagingId]) + #expect(code == .success) + let request = try #require(await rig.server.requests.first) + #expect(try arguments(of: request)["query"]?.stringValue == "SELECT 1\n") + } + } + + @Test("No SQL from a terminal, an empty statement, or an unreadable file is a usage error") + func missingSql() async throws { + try await withRig { rig in + #expect(await rig.runner().run(arguments: ["query", "-c", stagingId]) == .usage) + #expect(await rig.runner().run(arguments: ["query", "-c", stagingId, " "]) == .usage) + #expect(await rig.runner().run(arguments: ["query", "-c", stagingId, "-f", "/nope.sql"]) == .usage) + #expect(await rig.server.requests.isEmpty) + } + } + + @Test("A write that returns no rows reports the count on standard error only") + func rowsAffected() async throws { + try await withRig { rig in + let result: JsonValue = .object([ + "columns": .array([]), "rows": .array([]), "row_count": .int(0), + "rows_affected": .int(3), "is_truncated": .bool(false) + ]) + await rig.server.setResponse(try success(result)) + let code = await rig.runner().run(arguments: ["query", "-c", stagingId, "UPDATE t SET a = 1"]) + #expect(code == .success) + #expect(rig.console.output.isEmpty) + #expect(rig.console.errors == "3 rows affected.\n") + } + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLITokenStoreTests.swift b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLITokenStoreTests.swift new file mode 100644 index 0000000000..fa86224f44 --- /dev/null +++ b/apple/Packages/TableProMacCore/Tests/TableProMCPTests/CommandLine/CLITokenStoreTests.swift @@ -0,0 +1,35 @@ +import Foundation +@testable import TableProMCP +import Testing + +struct CLITokenStoreTests { + private func scratchDirectory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("cli-token-\(UUID().uuidString)", isDirectory: true) + } + + @Test("A sandboxed run keeps its token in the sandbox, never the login Keychain") + func sandboxUsesAFile() { + let sandbox = scratchDirectory() + let store = CLITokenStores.current(environment: ["TABLEPRO_UI_TEST_SANDBOX": sandbox.path]) + #expect(store is CLIFileTokenStore) + #expect(CLITokenStores.current(environment: [:]) is CLIKeychainTokenStore) + #expect(CLITokenStores.current(environment: ["TABLEPRO_UI_TEST_SANDBOX": " "]) is CLIKeychainTokenStore) + } + + @Test("The file store round-trips a token that only its owner can read") + func fileRoundTrip() throws { + let directory = scratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("TablePro/cli-token") + let store = CLIFileTokenStore(url: url) + + #expect(try store.read() == nil) + try store.save("tp_one") + try store.save("tp_two") + #expect(try store.read() == "tp_two") + let permissions = try FileManager.default.attributesOfItem(atPath: url.path)[.posixPermissions] as? NSNumber + #expect(permissions?.intValue == 0o600) + store.delete() + #expect(try store.read() == nil) + } +} diff --git a/apple/TablePro/CLI/CLIMain.swift b/apple/TablePro/CLI/CLIMain.swift new file mode 100644 index 0000000000..967e9bcc68 --- /dev/null +++ b/apple/TablePro/CLI/CLIMain.swift @@ -0,0 +1,73 @@ +import Darwin +import Foundation +import TableProMCP +import TableProUtilities + +@main +struct TableProCLI { + static func main() async { + let console = StandardConsole() + let code = await CLIRunner.live(console: console).run(arguments: Array(CommandLine.arguments.dropFirst())) + console.flush() + // A full disk or a closed file must not pass for a complete export. + if let failure = console.outputFailure, code == .success { + console.err("tablepro: could not write the output: \(failure.localizedDescription)\n") + exit(CLIExitCode.failure.rawValue) + } + exit(code.rawValue) + } +} + +private final class StandardConsole: CLIConsole, @unchecked Sendable { + private static let flushThreshold = 64 * 1_024 + + private let lock = NSLock() + private var pending = Data() + private var firstFailure: Error? + + var outputFailure: Error? { + lock.withLock { firstFailure } + } + + func out(_ text: String) { + let ready: Data? = lock.withLock { + pending.append(contentsOf: text.utf8) + guard pending.count >= Self.flushThreshold else { return nil } + defer { pending.removeAll(keepingCapacity: true) } + return pending + } + if let ready { + writeOutput(ready) + } + } + + func err(_ text: String) { + flush() + Self.write(Data(text.utf8), to: STDERR_FILENO) + } + + func flush() { + let ready: Data = lock.withLock { + defer { pending.removeAll(keepingCapacity: true) } + return pending + } + guard !ready.isEmpty else { return } + writeOutput(ready) + } + + private func writeOutput(_ data: Data) { + do { + try DescriptorWrite.allBytes(data, to: STDOUT_FILENO) + } catch { + lock.withLock { + if firstFailure == nil { + firstFailure = error + } + } + } + } + + private static func write(_ data: Data, to descriptor: Int32) { + try? DescriptorWrite.allBytes(data, to: descriptor) + } +} diff --git a/apple/TablePro/CLI/tablepro b/apple/TablePro/CLI/tablepro index b9a2441dd8..742b4b05f0 100755 --- a/apple/TablePro/CLI/tablepro +++ b/apple/TablePro/CLI/tablepro @@ -1,2 +1,13 @@ #!/bin/sh +case "$1" in + query | connections) + # Linked into a PATH directory, so the bundle is found through the link. + cli="$(/usr/bin/dirname "$(/usr/bin/readlink -f "$0")")/../../MacOS/tablepro-cli" + if [ ! -x "$cli" ]; then + echo "tablepro: $1 needs this script linked from TablePro.app, not copied out of it." >&2 + exit 2 + fi + exec "$cli" "$@" + ;; +esac exec open -b com.TablePro "$@" diff --git a/apple/TablePro/Core/MCP/MCPAuthPolicy.swift b/apple/TablePro/Core/MCP/MCPAuthPolicy.swift index 74d941beb2..14c0cc6b14 100644 --- a/apple/TablePro/Core/MCP/MCPAuthPolicy.swift +++ b/apple/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -71,7 +71,8 @@ public actor MCPAuthPolicy { principal: MCPPrincipal, tool: MCPToolName, connectionId: UUID?, - sql: String? = nil + sql: String? = nil, + readOnly: Bool = false ) async throws -> AuthDecision { if let denial = Self.adminScopeDenial(principal: principal, tool: tool) { return denial @@ -97,6 +98,11 @@ public actor MCPAuthPolicy { return .denied(reason: String(localized: "Token does not have access to this connection")) } + // Before the approval prompt, so nobody is asked about a statement the request already ruled out. + if readOnly, Self.intendsToWrite(tool: tool, sql: sql, databaseType: snapshot.databaseType) { + return .denied(reason: MCPStatementGate.readOnlyRefusal) + } + if let denial = Self.writeScopeDenial( principal: principal, tool: tool, @@ -162,13 +168,15 @@ public actor MCPAuthPolicy { principal: MCPPrincipal, tool: MCPToolName, connectionId: UUID?, - sql: String? = nil + sql: String? = nil, + readOnly: Bool = false ) async throws { let decision = try await authorize( principal: principal, tool: tool, connectionId: connectionId, - sql: sql + sql: sql, + readOnly: readOnly ) switch decision { @@ -360,6 +368,7 @@ public actor MCPAuthPolicy { return String(format: String(localized: "Allow %@?"), operationLabel) } + // The MCP setting governs only MCP queries; command line queries are always recorded, as AppleScript ones are. func logQuery( sql: String, connectionId: UUID, @@ -367,12 +376,15 @@ public actor MCPAuthPolicy { executionTime: TimeInterval, rowCount: Int, wasSuccessful: Bool, - errorMessage: String? + errorMessage: String?, + source: QueryHistorySource = .mcp ) async { - let shouldLog = await MainActor.run { - AppSettingsManager.shared.mcp.logQueriesInHistory + if source == .mcp { + let shouldLog = await MainActor.run { + AppSettingsManager.shared.mcp.logQueriesInHistory + } + guard shouldLog else { return } } - guard shouldLog else { return } let databaseType = await connectionResolver(connectionId)?.databaseType ?? "" @@ -382,7 +394,7 @@ public actor MCPAuthPolicy { connectionId: connectionId, databaseName: databaseName, databaseType: DatabaseType(rawValue: databaseType), - source: .mcp, + source: source, executionTime: executionTime, rowCount: rowCount, wasSuccessful: wasSuccessful, diff --git a/apple/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift b/apple/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift index e4fa848767..df4650c1b0 100644 --- a/apple/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift +++ b/apple/TablePro/Core/MCP/Protocol/Handlers/ToolsCallHandler.swift @@ -86,7 +86,8 @@ public struct ToolsCallHandler: MCPMethodHandler { principal: context.principal, tool: toolName, connectionId: connectionId, - sql: Self.sqlArgument(in: arguments) + sql: Self.sqlArgument(in: arguments), + readOnly: arguments["read_only"]?.boolValue == true ) } catch let error as DatabaseAccessError { Self.audit( diff --git a/apple/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift b/apple/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift index 58226b9c89..ca3d78ca4f 100644 --- a/apple/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift +++ b/apple/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift @@ -39,7 +39,10 @@ public struct ExecuteQueryTool: MCPToolImplementation { minimum: 1 ), "database": MCPToolSchema.database, - "schema": MCPToolSchema.schema + "schema": MCPToolSchema.schema, + "read_only": MCPToolSchema.boolean( + String(localized: "Refuse the statement unless it only reads, even where the connection allows writes.") + ) ], required: ["connection_id", "query"] ) @@ -57,10 +60,11 @@ public struct ExecuteQueryTool: MCPToolImplementation { ) async throws -> MCPToolCallResult { try MCPArgumentDecoder.rejectUnknownKeys( arguments, - allowed: MCPScopeArguments.keys.union(["query", "max_rows", "timeout_seconds"]) + allowed: MCPScopeArguments.keys.union(["query", "max_rows", "timeout_seconds", "read_only"]) ) let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") let query = try MCPArgumentDecoder.requireNonEmptyString(arguments, key: "query") + let readOnly = try MCPArgumentDecoder.optionalBool(arguments, key: "read_only", default: false) guard (query as NSString).length <= Self.maximumQueryBytes else { throw MCPToolExecutionError.invalidArgument( @@ -81,6 +85,7 @@ public struct ExecuteQueryTool: MCPToolImplementation { meta: meta, allowsDestructive: false, allowsMultiStatement: ExternalStatementGate.acceptsScripts(on: meta.databaseType), + readOnly: readOnly, operationLabel: String(localized: "a query"), context: context, services: services diff --git a/apple/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift b/apple/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift index 93be6b532c..b6a9d1d8ca 100644 --- a/apple/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift +++ b/apple/TablePro/Core/MCP/Protocol/Tools/MCPStatementGate.swift @@ -8,11 +8,17 @@ enum MCPStatementGate { meta: ToolConnectionMetadata, allowsDestructive: Bool, allowsMultiStatement: Bool = false, + readOnly: Bool = false, forcesUserConsent: Bool = false, operationLabel: String, context: MCPRequestContext, services: MCPToolServices ) async throws -> QueryClassification { + // The caller lowering its own reach, so its refusal names the request rather than the connection. + if readOnly, QueryClassifier.classify(sql, databaseType: meta.databaseType).tier != .safe { + throw MCPToolExecutionError.denied(Self.readOnlyRefusal) + } + let classification = try translating { try ExternalStatementGate.classify( ExternalStatementGate.Statement( @@ -72,6 +78,8 @@ enum MCPStatementGate { return classification } + static let readOnlyRefusal = String(localized: "This request is read only, and the statement is not a read.") + static func requiresUserConsent( classification: QueryClassification, sql: String, diff --git a/apple/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift b/apple/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift index bbca6a2e32..9ec326ceb4 100644 --- a/apple/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift +++ b/apple/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift @@ -29,10 +29,16 @@ enum ToolQueryExecutor { principal: context.principal, cancellation: context.cancellation, secrets: secrets, - unit: unit + unit: unit, + source: historySource(for: context) ) } + // A label from the client's own `clientInfo`, as Connected Clients shows; access is decided by the token. + static func historySource(for context: MCPRequestContext) -> QueryHistorySource { + context.meta.clientInfo?.name == CLIClientIdentity.name ? .commandLine : .mcp + } + static func executeAndLog( services: MCPToolServices, query: String, @@ -42,9 +48,17 @@ enum ToolQueryExecutor { principal: MCPPrincipal, cancellation: MCPCancellationToken? = nil, secrets: [String] = [], - unit: Unit = .statement + unit: Unit = .statement, + source: QueryHistorySource = .mcp ) async throws -> JsonValue { - try await logged(services: services, query: query, scope: scope, principal: principal, secrets: secrets) { + try await logged( + services: services, + query: query, + scope: scope, + principal: principal, + secrets: secrets, + source: source + ) { try await run( unit, services: services, @@ -72,7 +86,8 @@ enum ToolQueryExecutor { query: query, scope: scope, principal: context.principal, - secrets: secrets + secrets: secrets, + source: historySource(for: context) ) { let outcome = try await services.connectionBridge.runStatement( scope: scope, @@ -91,6 +106,7 @@ enum ToolQueryExecutor { scope: DatabaseScope, principal: MCPPrincipal, secrets: [String], + source: QueryHistorySource, operation: () async throws -> (Output, Int) ) async throws -> Output { let connectionId = scope.connectionId @@ -107,7 +123,8 @@ enum ToolQueryExecutor { executionTime: elapsed, rowCount: rowCount, wasSuccessful: true, - errorMessage: nil + errorMessage: nil, + source: source ) await reportMcpQueryFinished( .succeeded(OperationSummary(rowsReturned: rowCount)), @@ -136,7 +153,8 @@ enum ToolQueryExecutor { executionTime: elapsed, rowCount: 0, wasSuccessful: false, - errorMessage: redacted + errorMessage: redacted, + source: source ) MCPAuditLogger.logQueryExecuted( principal: principal, diff --git a/apple/TablePro/Models/Query/QueryHistorySource.swift b/apple/TablePro/Models/Query/QueryHistorySource.swift index 1ee82d9cbd..a4f010668d 100644 --- a/apple/TablePro/Models/Query/QueryHistorySource.swift +++ b/apple/TablePro/Models/Query/QueryHistorySource.swift @@ -9,6 +9,7 @@ enum QueryHistorySource: String, Codable, CaseIterable, Sendable, Identifiable { case dataImport = "import" case mcp case script + case commandLine = "command_line" var id: String { rawValue } @@ -22,6 +23,7 @@ enum QueryHistorySource: String, Codable, CaseIterable, Sendable, Identifiable { case .dataImport: return String(localized: "Imports") case .mcp: return String(localized: "AI and MCP") case .script: return String(localized: "AppleScript") + case .commandLine: return String(localized: "Command Line") } } @@ -35,22 +37,19 @@ enum QueryHistorySource: String, Codable, CaseIterable, Sendable, Identifiable { case .dataImport: return "square.and.arrow.down" case .mcp: return "sparkles" case .script: return "applescript" + case .commandLine: return "terminal" } } static let userAuthored: Set = [.editor, .explain] - /// The full set as it stood before `script` was added. - /// - /// A stored filter holding exactly these was the user choosing **Everything**, and decoding it - /// verbatim would silently drop the new source: their filter would start hiding scripted queries - /// and the toolbar would change from "Everything" to a count. Widening only this exact set leaves - /// a genuinely custom selection alone. - private static let allBeforeScript: Set = [ - .editor, .explain, .tableBrowse, .rowEdit, .structureDDL, .dataImport, .mcp + // The whole list before each later source was added: a stored filter equal to one of these meant Everything. + private static let earlierAllSources: [Set] = [ + [.editor, .explain, .tableBrowse, .rowEdit, .structureDDL, .dataImport, .mcp], + [.editor, .explain, .tableBrowse, .rowEdit, .structureDDL, .dataImport, .mcp, .script] ] static func migratingStoredSelection(_ stored: Set) -> Set { - stored == allBeforeScript ? Set(allCases) : stored + earlierAllSources.contains(stored) ? Set(allCases) : stored } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index a9ef84d3eb..ec17ab982d 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -143236,6 +143236,9 @@ } } } + }, + "Refuse the statement unless it only reads, even where the connection allows writes." : { + }, "Refused %@: this statement ran as a read." : { "extractionState" : "manual", @@ -196374,6 +196377,9 @@ } } } + }, + "This request is read only, and the statement is not a read." : { + }, "This request is too large for Apple Intelligence, even with only the columns the statement uses. Select a smaller part of the statement." : { diff --git a/apple/TableProTests/Core/MCP/CLIPairingContractTests.swift b/apple/TableProTests/Core/MCP/CLIPairingContractTests.swift new file mode 100644 index 0000000000..e5bd5729df --- /dev/null +++ b/apple/TableProTests/Core/MCP/CLIPairingContractTests.swift @@ -0,0 +1,55 @@ +import Foundation +@testable import TablePro +import TableProMCP +import Testing + +@MainActor +struct CLIPairingContractTests { + private func pairingRequest(_ request: CLIPairingRequest) throws -> PairingRequest { + let link = try #require(request.link(callbackPort: 51_234)) + guard case .some(.success(.pairIntegration(let parsed))) = URLClassifier.classify(link) else { + Issue.record("The app did not read the command's link as a pairing request") + throw CancellationError() + } + return parsed + } + + @Test("The app reads the command's link as a read-only pairing with a loopback callback") + func appAcceptsTheLink() throws { + let request = CLIPairingRequest.random() + let parsed = try pairingRequest(request) + #expect(parsed.clientName == CLIClientIdentity.pairingName) + #expect(parsed.challenge == request.challenge) + #expect(parsed.state == request.state) + #expect(parsed.responseMode == .query) + #expect(parsed.scopeRequest.permissions == .readOnly) + #expect(parsed.requestedConnectionIds == nil) + #expect(try PairingRedirectValidator.validate(parsed.redirectURL).kind == .loopbackHttp) + try PairingPkceValidator.validateChallenge(parsed.challenge) + try PairingPkceValidator.validateVerifier(request.verifier) + #expect(PairingExchangeStore.sha256Base64Url(of: request.verifier) == request.challenge) + } + + @Test("The app's approve and deny redirects reach the command as approved and denied") + func redirectsRoundTrip() throws { + let request = CLIPairingRequest.random() + let parsed = try pairingRequest(request) + + let approved = try #require(PairingRedirectBuilder.success( + base: parsed.redirectURL, code: "code-1", state: parsed.state, mode: parsed.responseMode + )) + #expect(CLIPairingCallback.parse(requestTarget: Self.target(of: approved), state: request.state) == .approved(code: "code-1")) + + let denied = try #require(PairingRedirectBuilder.denied( + base: parsed.redirectURL, state: parsed.state, mode: parsed.responseMode + )) + #expect(CLIPairingCallback.parse(requestTarget: Self.target(of: denied), state: request.state) == .denied) + } + + // What a browser puts on the request line for the URL it was handed. + private static func target(of url: URL) -> String { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let query = components?.percentEncodedQuery.map { "?\($0)" } ?? "" + return (components?.percentEncodedPath ?? "/") + query + } +} diff --git a/apple/TableProTests/Core/MCP/MCPCommandLineHistoryTests.swift b/apple/TableProTests/Core/MCP/MCPCommandLineHistoryTests.swift new file mode 100644 index 0000000000..e88b579528 --- /dev/null +++ b/apple/TableProTests/Core/MCP/MCPCommandLineHistoryTests.swift @@ -0,0 +1,78 @@ +import Foundation +@testable import TablePro +import TableProMCP +import Testing + +private final class RecordingHistory: QueryHistoryRecording, @unchecked Sendable { + private let lock = NSLock() + private var recorded: [QueryHistoryRecordRequest] = [] + + var requests: [QueryHistoryRecordRequest] { lock.withLock { recorded } } + + func record(_ request: QueryHistoryRecordRequest) async -> Bool { + lock.withLock { recorded.append(request) } + return true + } +} + +struct MCPCommandLineHistoryTests { + @Test("A request from the tablepro command is filed under Command Line, any other under MCP") + func sourceFollowsTheClient() { + let cli = MCPToolTestHarness.context(clientInfo: MCPImplementation(name: CLIClientIdentity.name, version: "1")) + let other = MCPToolTestHarness.context(clientInfo: MCPImplementation(name: "claude-code", version: "2")) + #expect(ToolQueryExecutor.historySource(for: cli) == .commandLine) + #expect(ToolQueryExecutor.historySource(for: other) == .mcp) + } + + @Test("A command line query is recorded under its own source") + func commandLineQueryIsRecorded() async { + let history = RecordingHistory() + let connectionId = UUID() + let policy = MCPAuthPolicy( + connectionResolver: { _ in MCPToolTestHarness.snapshot() }, + historyRecorder: history + ) + await policy.logQuery( + sql: "SELECT 1", + connectionId: connectionId, + databaseName: "shop", + executionTime: 0.01, + rowCount: 1, + wasSuccessful: true, + errorMessage: nil, + source: .commandLine + ) + let recorded = history.requests + #expect(recorded.count == 1) + #expect(recorded.first?.source == .commandLine) + #expect(recorded.first?.connectionId == connectionId) + } +} + +extension GlobalStateSuites { + struct MCPCommandLineHistorySettingTests { + @Test("Turning off MCP history leaves command line queries recorded", .appSettings { + $0.mcp.logQueriesInHistory = false + }) + func settingGovernsOnlyMcp() async { + let history = RecordingHistory() + let policy = MCPAuthPolicy( + connectionResolver: { _ in MCPToolTestHarness.snapshot() }, + historyRecorder: history + ) + for source in [QueryHistorySource.mcp, .commandLine] { + await policy.logQuery( + sql: "SELECT 1", + connectionId: UUID(), + databaseName: "shop", + executionTime: 0, + rowCount: 1, + wasSuccessful: true, + errorMessage: nil, + source: source + ) + } + #expect(history.requests.map(\.source) == [.commandLine]) + } + } +} diff --git a/apple/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryReadOnlyTests.swift b/apple/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryReadOnlyTests.swift new file mode 100644 index 0000000000..a201cc789b --- /dev/null +++ b/apple/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryReadOnlyTests.swift @@ -0,0 +1,111 @@ +import Foundation +@testable import TablePro +import TableProMCP +import Testing + +extension GlobalStateSuites { + @MainActor + struct ExecuteQueryReadOnlyTests { + private struct Observation { + let refusal: String? + let succeeded: Bool + let prompts: [WritePrompt] + let received: [String] + } + + private func rig() -> WritePolicyRig { + WritePolicyRig( + connection: WritePolicyRig.connection( + type: .postgresql, + externalAccess: .readWrite, + aiPolicy: .askEachTime, + safeMode: .silent + ), + prompts: WritePolicyPrompts(.approveAll), + live: true + ) + } + + private func services(_ rig: WritePolicyRig) -> MCPToolServices { + MCPToolServices( + connectionBridge: MCPConnectionBridge(), + authPolicy: rig.authPolicy, + settingsProvider: { MCPSettings() }, + queryHistoryManager: ToolTestQueryHistoryStore() + ) + } + + private func arguments(_ rig: WritePolicyRig, _ sql: String, readOnly: Bool) -> JsonValue { + var fields: [String: JsonValue] = [ + "connection_id": .string(rig.connection.id.uuidString), + "query": .string(sql) + ] + if readOnly { + fields["read_only"] = .bool(true) + } + return .object(fields) + } + + private func throughHandler(_ sql: String, readOnly: Bool) async -> Observation { + let rig = rig() + defer { rig.tearDown() } + let params = MCPToolTestHarness.toolCallParams( + name: ExecuteQueryTool.name, + arguments: arguments(rig, sql, readOnly: readOnly) + ) + let context = MCPToolTestHarness.context(params: params) + do { + let result = try await ToolsCallHandler(services: services(rig)).handle(params: params, context: context) + let isError = result.payload["isError"]?.boolValue == true + let text = result.payload["content"]?.arrayValue?.first?["text"]?.stringValue + return Observation( + refusal: isError ? text : nil, + succeeded: !isError, + prompts: rig.prompts.prompts, + received: rig.driver.received + ) + } catch let error as MCPProtocolError { + return Observation(refusal: error.message, succeeded: false, prompts: rig.prompts.prompts, received: rig.driver.received) + } catch { + return Observation(refusal: "\(error)", succeeded: false, prompts: rig.prompts.prompts, received: rig.driver.received) + } + } + + @Test("A write with read_only is refused before the approval prompt and never reaches the driver") + func writeRefusedBeforePrompt() async { + let observed = await throughHandler("UPDATE accounts SET balance = 0", readOnly: true) + #expect(observed.refusal?.contains(MCPStatementGate.readOnlyRefusal) == true) + #expect(observed.prompts.isEmpty) + #expect(observed.received.isEmpty) + } + + @Test("A read with read_only runs as it would without it") + func readRuns() async { + let observed = await throughHandler("SELECT 1", readOnly: true) + #expect(observed.succeeded) + #expect(observed.received.contains { $0.contains("SELECT 1") }) + } + + @Test("Without read_only the same write still runs on a Read & Write connection") + func flagOnlyLowers() async { + let observed = await throughHandler("UPDATE accounts SET balance = 0", readOnly: false) + #expect(observed.succeeded) + #expect(observed.received.contains { $0.contains("UPDATE accounts") }) + } + + @Test("The tool refuses a read_only write on its own, before Safe Mode asks anything") + func toolRefusesWithoutTheHandler() async throws { + let rig = rig() + defer { rig.tearDown() } + let result = try await ExecuteQueryTool().call( + arguments: arguments(rig, "DELETE FROM accounts", readOnly: true), + context: MCPToolTestHarness.context(), + services: services(rig) + ) + #expect(result.isError) + #expect(MCPToolTestHarness.errorText(result) == "denied: \(MCPStatementGate.readOnlyRefusal)") + #expect(rig.prompts.prompts.isEmpty) + #expect(rig.driver.received.isEmpty) + } + } +} diff --git a/apple/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift b/apple/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift index 2f4354119a..eef189a8ea 100644 --- a/apple/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift +++ b/apple/TableProTests/Core/MCP/Protocol/Tools/ExecuteQueryToolTests.swift @@ -124,6 +124,18 @@ struct ExecuteQueryToolTests { } } + @Test("read_only is declared, and anything but a boolean is a protocol error") + func readOnlyArgument() async throws { + #expect(ExecuteQueryTool.inputSchema["properties"]?["read_only"]?["type"]?.stringValue == "boolean") + await #expect(throws: MCPProtocolError.self) { + _ = try await call(.object([ + "connection_id": .string(UUID().uuidString), + "query": .string("SELECT 1"), + "read_only": .string("yes") + ])) + } + } + @Test("A cancelled request is reported as cancelled, not as a query failure") func cancellationIsReported() async throws { let cancellation = MCPCancellationToken() diff --git a/apple/TableProTests/Core/MCP/Protocol/Tools/MCPToolTestHarness.swift b/apple/TableProTests/Core/MCP/Protocol/Tools/MCPToolTestHarness.swift index 0d73afb037..851aa6fae5 100644 --- a/apple/TableProTests/Core/MCP/Protocol/Tools/MCPToolTestHarness.swift +++ b/apple/TableProTests/Core/MCP/Protocol/Tools/MCPToolTestHarness.swift @@ -111,11 +111,12 @@ enum MCPToolTestHarness { static func meta( protocolVersion: MCPProtocolVersion = .latest, clientCapabilities: MCPClientCapabilities = .none, - progressToken: MCPProgressToken? = nil + progressToken: MCPProgressToken? = nil, + clientInfo: MCPImplementation = MCPImplementation(name: "tool-tests", version: "1.0") ) -> MCPRequestMeta { MCPRequestMeta( protocolVersion: protocolVersion, - clientInfo: MCPImplementation(name: "tool-tests", version: "1.0"), + clientInfo: clientInfo, clientCapabilities: clientCapabilities, progressToken: progressToken ) @@ -128,13 +129,14 @@ enum MCPToolTestHarness { progressToken: MCPProgressToken? = nil, cancellation: MCPCancellationToken = MCPCancellationToken(), sink: ToolTestResponderSink = ToolTestResponderSink(), - requestId: JsonRpcId = .number(1) + requestId: JsonRpcId = .number(1), + clientInfo: MCPImplementation = MCPImplementation(name: "tool-tests", version: "1.0") ) -> MCPRequestContext { let responder = MCPResponder(sink: sink, requestId: requestId) return MCPRequestContext( requestId: requestId, params: params, - meta: meta(clientCapabilities: clientCapabilities, progressToken: progressToken), + meta: meta(clientCapabilities: clientCapabilities, progressToken: progressToken, clientInfo: clientInfo), principal: principal ?? self.principal(), responder: responder, progress: MCPProgressEmitter(progressToken: progressToken, responder: responder), diff --git a/apple/TableProTests/Models/QueryHistorySourceTests.swift b/apple/TableProTests/Models/QueryHistorySourceTests.swift index 766603366c..14d51c9cba 100644 --- a/apple/TableProTests/Models/QueryHistorySourceTests.swift +++ b/apple/TableProTests/Models/QueryHistorySourceTests.swift @@ -17,6 +17,8 @@ struct QueryHistorySourceTests { #expect(QueryHistorySource.structureDDL.rawValue == "structure_ddl") #expect(QueryHistorySource.dataImport.rawValue == "import") #expect(QueryHistorySource.mcp.rawValue == "mcp") + #expect(QueryHistorySource.script.rawValue == "script") + #expect(QueryHistorySource.commandLine.rawValue == "command_line") } @Test("the default filter hides app-generated statements") @@ -28,6 +30,7 @@ struct QueryHistorySourceTests { #expect(QueryHistorySource.userAuthored.contains(.structureDDL) == false) #expect(QueryHistorySource.userAuthored.contains(.dataImport) == false) #expect(QueryHistorySource.userAuthored.contains(.mcp) == false) + #expect(QueryHistorySource.userAuthored.contains(.commandLine) == false) } @Test("every source is presentable") @@ -134,6 +137,14 @@ struct HistoryDateRangeTests { #expect(QueryHistorySource.migratingStoredSelection(before) == Set(QueryHistorySource.allCases)) } + @Test("The all-sources selection from before Command Line widens to include it") + func everythingMigratesToIncludeCommandLine() { + let before: Set = [ + .editor, .explain, .tableBrowse, .rowEdit, .structureDDL, .dataImport, .mcp, .script + ] + #expect(QueryHistorySource.migratingStoredSelection(before) == Set(QueryHistorySource.allCases)) + } + @Test("A custom selection is left exactly as the user set it") func customSelectionIsUntouched() { let custom: Set = [.editor, .mcp] diff --git a/apple/project.yml b/apple/project.yml index f347a3508e..b2d1dc8042 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -288,6 +288,8 @@ targets: excludes: # main() for the tablepro-mcp helper. - CLI/BridgeMain.swift + # main() for the tablepro-cli helper behind `tablepro query`. + - CLI/CLIMain.swift # main() for the tablepro-imagerender helper, which is a whole executable on # its own: nothing in it is reachable from the app. - CLI/ImageRenderMain.swift @@ -340,6 +342,12 @@ targets: removeHeaders: false copy: destination: executables + - target: command-line + embed: true + codeSign: true + removeHeaders: false + copy: + destination: executables # Plugins shipped inside the app bundle: the BundledPlugins aggregate lists the same set. - target: MySQLDriver embed: true @@ -513,6 +521,27 @@ targets: SWIFT_APPROACHABLE_CONCURRENCY: YES SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY: YES + # ─── tablepro query and tablepro connections, embedded as Contents/MacOS/tablepro-cli ─ + + command-line: + type: tool + platform: macOS + sources: + - TablePro/CLI/CLIMain.swift + dependencies: + - package: TableProMacCore + products: [TableProMCP, TableProUtilities] + scheme: {} + settings: + base: + ENABLE_HARDENED_RUNTIME: YES + PRODUCT_BUNDLE_IDENTIFIER: com.TablePro.tablepro-cli + # The app embeds it; installed on its own it turns the app archive into a generic one. + SKIP_INSTALL: YES + PRODUCT_NAME: tablepro-cli + SWIFT_APPROACHABLE_CONCURRENCY: YES + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY: YES + # ─── Out-of-process image rasterizer, embedded as Contents/MacOS/tablepro-imagerender ─ image-renderer: From 28ce672aadc0ba2f4487505eeaf888efb82ce1eb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 22:26:50 +0700 Subject: [PATCH 2/2] docs(changelog): add the tablepro query fragment --- apple/changelog.d/3762.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3762.added.md diff --git a/apple/changelog.d/3762.added.md b/apple/changelog.d/3762.added.md new file mode 100644 index 0000000000..e38eb706b4 --- /dev/null +++ b/apple/changelog.d/3762.added.md @@ -0,0 +1 @@ +Run a query from the shell with `tablepro query` and get the rows as CSV, TSV, JSON or a table.