From 9a8b6f3b7566ddf9895c8dab19752eaca1bc5fa5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 23 Sep 2026 19:11:00 +0700 Subject: [PATCH 1/2] feat(mcp): search every schema from search_schema when no schema is named --- CHANGELOG.md | 1 + .../Core/MCP/MCPConnectionBridge+Data.swift | 94 +++-- TablePro/Core/MCP/MCPSchemaSearch.swift | 167 +++++++++ .../Protocol/Tools/SchemaObjectTools.swift | 44 ++- .../Autocomplete/SQLSchemaProviderTests.swift | 5 + .../Core/MCP/MCPSchemaSearchTests.swift | 322 ++++++++++++++++++ docs/external-api/mcp-resources.mdx | 2 +- docs/external-api/mcp-tools.mdx | 19 +- 8 files changed, 603 insertions(+), 51 deletions(-) create mode 100644 TablePro/Core/MCP/MCPSchemaSearch.swift create mode 100644 TableProTests/Core/MCP/MCPSchemaSearchTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e28b1357..e66d421a45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Show Previous Window Tab** and **Show Next Window Tab** for window tabs, with no default shortcut. - SQLite 3.53.4 built into the SQLite and libSQL drivers in place of the macOS copy. - One-time reset of Open Quickly's Recent query history, and of its objects on connections that switch databases. +- Tables and views from every schema in the MCP `search_schema` tool when no schema is named. (#3048) ### Removed diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift index 693ff67b05..3afd64f966 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift @@ -166,45 +166,65 @@ extension MCPConnectionBridge { return pagination.clampedRowCount(request.limit) } - func searchSchema(scope: DatabaseScope, term: String, limit: Int) async throws -> JsonValue { - try await ensureConnected(scope.connectionId) - let schema = scope.schema - let needle = term.lowercased() - - let matches = try await DatabaseManager.shared.withMetadataDriver( - scope: scope, - workload: .bulk - ) { driver -> [JsonValue] in - let tables = MCPConnectionBridge.sortedTables(try await driver.fetchTables(schema: schema)) - var found: [JsonValue] = [] - for table in tables where table.name.lowercased().contains(needle) { - found.append(.object([ - "kind": .string("table"), - "name": .string(table.name), - "object_type": .string(table.type.rawValue), - "schema": table.schema.map(JsonValue.string) ?? JsonValue.null - ])) - if found.count >= limit { return found } - } - let allColumns = (try? await driver.fetchAllColumns()) ?? [:] - for tableName in allColumns.keys.sorted() { - for column in allColumns[tableName] ?? [] where column.name.lowercased().contains(needle) { - found.append(.object([ - "kind": .string("column"), - "name": .string(column.name), - "table": .string(tableName), - "data_type": .string(column.dataType) - ])) - if found.count >= limit { return found } - } - } - return found + func searchSchema(scope: DatabaseScope, term: String, limit: Int, schemaIsNamed: Bool) async throws -> JsonValue { + let databaseType = try await ensureConnected(scope.connectionId) + let tableReach = await MainActor.run { + MCPSchemaSearch.tableReach( + schemaIsNamed: schemaIsNamed, + grouping: PluginManager.shared.databaseGroupingStrategy(for: databaseType), + systemSchemas: Set(PluginManager.shared.systemSchemaNames(for: databaseType)) + ) } - return .object([ + let result = try await MCPSchemaSearch.run( + MCPSchemaSearch.Request(scope: scope, term: term, limit: limit, tableReach: tableReach), + metadata: DatabaseManager.shared + ) + return Self.encode(search: result, term: term, scope: scope, schemaIsNamed: schemaIsNamed) + } + + static func encode( + search result: MCPSchemaSearch.Result, + term: String, + scope: DatabaseScope, + schemaIsNamed: Bool + ) -> JsonValue { + var payload: [String: JsonValue] = [ "term": .string(term), - "matches": .array(matches), - "is_truncated": .bool(matches.count >= limit) - ]) + "database": .string(scope.database), + "schema": schemaIsNamed ? nullable(scope.schema) : .null, + "matches": .array(result.matches.map(encode(match:))), + "is_truncated": .bool(result.isTruncated), + "unlisted_schemas": .array(result.unlistedSchemas.map(JsonValue.string)), + "column_search": .string(result.columnSearch.outcome) + ] + if case .searched(let schema) = result.columnSearch { + payload["columns_schema"] = nullable(schema) + } + return .object(payload) + } + + static func encode(match: MCPSchemaSearch.Match) -> JsonValue { + switch match { + case .table(let name, let schema, let type): + return .object([ + "kind": .string("table"), + "name": .string(name), + "schema": nullable(schema), + "object_type": .string(type.rawValue) + ]) + case .column(let name, let table, let schema, let dataType): + return .object([ + "kind": .string("column"), + "name": .string(name), + "table": .string(table), + "schema": nullable(schema), + "data_type": .string(dataType) + ]) + } + } + + private static func nullable(_ value: String?) -> JsonValue { + value.map(JsonValue.string) ?? .null } func insertRows( diff --git a/TablePro/Core/MCP/MCPSchemaSearch.swift b/TablePro/Core/MCP/MCPSchemaSearch.swift new file mode 100644 index 0000000000..46431c5f73 --- /dev/null +++ b/TablePro/Core/MCP/MCPSchemaSearch.swift @@ -0,0 +1,167 @@ +// +// MCPSchemaSearch.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +/// What `search_schema` finds for one term. +/// +/// A caller that names no schema is asking where something lives, so on an engine whose tables +/// live in schemas the tables and views come from every one of them, through the same listing Open +/// Quickly and the sidebar filter search. It is read fresh on every call rather than taken from the +/// sidebar's copy, which only learns of catalog changes the app makes itself: a table a migration +/// created from a terminal would stay invisible to the tool until the next reconnect. +/// +/// Columns come from one schema either way. Every schema's columns would be a catalog read of the +/// whole database for each search, so the result names the schema they came from, and a caller +/// looks in another by naming it. +internal enum MCPSchemaSearch { + internal enum TableReach: Equatable, Sendable { + case scopeSchema + case everySchema(excluding: Set) + } + + internal struct Request: Sendable { + internal let scope: DatabaseScope + internal let term: String + internal let limit: Int + internal let tableReach: TableReach + } + + internal enum Match: Equatable, Sendable { + case table(name: String, schema: String?, type: TableInfo.TableType) + case column(name: String, table: String, schema: String?, dataType: String) + } + + internal enum ColumnSearch: Equatable, Sendable { + case searched(schema: String?) + case limitReached + case failed + + internal static let outcomes = [Self.searched(schema: nil), .limitReached, .failed].map(\.outcome) + + internal var outcome: String { + switch self { + case .searched: "searched" + case .limitReached: "limit_reached" + case .failed: "failed" + } + } + } + + internal struct Result: Equatable, Sendable { + internal let matches: [Match] + internal let isTruncated: Bool + internal let unlistedSchemas: [String] + internal let columnSearch: ColumnSearch + } + + private struct ColumnRead: Sendable { + let schema: String? + let matches: [Match] + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "MCPSchemaSearch") + + internal static func tableReach( + schemaIsNamed: Bool, + grouping: GroupingStrategy, + systemSchemas: Set + ) -> TableReach { + guard !schemaIsNamed, DatabaseTreeMetadataService.listsTablesPerSchema(grouping) else { + return .scopeSchema + } + return .everySchema(excluding: systemSchemas) + } + + internal static func run(_ request: Request, metadata: ScopedMetadataProviding) async throws -> Result { + let needle = request.term.lowercased() + let listing = try await tables(reaching: request.tableReach, in: request.scope, metadata: metadata) + let listedSchema = request.tableReach == .scopeSchema ? request.scope.schema : nil + let tableMatches = ordered( + listing.tables.filter { $0.name.lowercased().contains(needle) }, + preferring: request.scope.schema + ).map { table in + Match.table(name: table.name, schema: table.schema ?? listedSchema, type: table.type) + } + let unlisted = listing.unlistedSchemas.sorted() + + guard tableMatches.count <= request.limit else { + return Result( + matches: Array(tableMatches.prefix(request.limit)), + isTruncated: true, + unlistedSchemas: unlisted, + columnSearch: .limitReached + ) + } + + let room = request.limit - tableMatches.count + let columnRead: ColumnRead + do { + columnRead = try await columns(matching: needle, in: request.scope, metadata: metadata) + } catch is CancellationError { + throw CancellationError() + } catch { + logger.warning("[search] column read failed error=\(error.publicLogShape, privacy: .public)") + return Result( + matches: tableMatches, + isTruncated: false, + unlistedSchemas: unlisted, + columnSearch: .failed + ) + } + return Result( + matches: tableMatches + columnRead.matches.prefix(room), + isTruncated: columnRead.matches.count > room, + unlistedSchemas: unlisted, + columnSearch: .searched(schema: columnRead.schema) + ) + } + + /// The schema the caller is on leads, the way it wins ties in Open Quickly, so a limit that + /// clips the matches clips other schemas' first. + internal static func ordered(_ tables: [TableInfo], preferring schema: String?) -> [TableInfo] { + let sorted = MCPConnectionBridge.sortedTables(tables) + guard let schema else { return sorted } + return sorted.filter { $0.schema == schema } + sorted.filter { $0.schema != schema } + } + + private static func tables( + reaching reach: TableReach, + in scope: DatabaseScope, + metadata: ScopedMetadataProviding + ) async throws -> CatalogTableListing.Result { + switch reach { + case .everySchema(let excluded): + return try await CatalogTableListing.tables(in: scope, excludingSchemas: excluded, metadata: metadata) + case .scopeSchema: + let schema = scope.schema + let tables = try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in + try await driver.fetchTables(schema: schema) + } + return CatalogTableListing.Result(tables: tables, unlistedSchemas: []) + } + } + + private static func columns( + matching needle: String, + in scope: DatabaseScope, + metadata: ScopedMetadataProviding + ) async throws -> ColumnRead { + let fallbackSchema = scope.schema + return try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in + let schema = (driver as? SchemaSwitchable)?.currentSchema ?? fallbackSchema + let allColumns = try await driver.fetchAllColumns() + var matches: [Match] = [] + for table in allColumns.keys.sorted() { + for column in allColumns[table] ?? [] where column.name.lowercased().contains(needle) { + matches.append(.column(name: column.name, table: table, schema: schema, dataType: column.dataType)) + } + } + return ColumnRead(schema: schema, matches: matches) + } + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift b/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift index c970ccb799..f84fdefc0e 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift @@ -448,8 +448,9 @@ public struct SearchSchemaTool: MCPToolImplementation { public static let title: String? = String(localized: "Search Schema") public static let description = String( localized: """ - Find tables and columns whose name contains a substring, so a column can be located without \ - describing every table. + Find tables, views and columns whose name contains a substring, and the schema each one is in. \ + Without 'schema', tables and views are searched in every schema and columns in the current \ + one; name a schema to search only that schema, columns included. """ ) public static let requiredScopes: Set = [.toolsRead] @@ -471,7 +472,9 @@ public struct SearchSchemaTool: MCPToolImplementation { maximum: 500 ), "database": MCPToolSchema.database, - "schema": MCPToolSchema.schema + "schema": MCPToolSchema.string( + String(localized: "Schema to search, columns included. Omit to search tables and views in every schema.") + ) ], required: ["connection_id", "term"] ) @@ -479,8 +482,12 @@ public struct SearchSchemaTool: MCPToolImplementation { public static let outputSchema: JsonValue? = MCPToolSchema.object( properties: [ "term": MCPToolSchema.string(String(localized: "Term that was searched")), + "database": MCPToolSchema.string(String(localized: "Database that was searched")), + "schema": MCPToolSchema.nullableString( + String(localized: "Schema the search was narrowed to, null when none was named") + ), "matches": MCPToolSchema.array( - String(localized: "Matching tables first, then matching columns"), + String(localized: "Matching tables and views first, those in the current schema leading, then matching columns"), of: MCPToolSchema.object( properties: [ "kind": MCPToolSchema.string( @@ -489,16 +496,31 @@ public struct SearchSchemaTool: MCPToolImplementation { ), "name": MCPToolSchema.string(String(localized: "Matched name")), "table": MCPToolSchema.string(String(localized: "Owning table, for a column match")), - "schema": MCPToolSchema.nullableString(String(localized: "Schema, for a table match")), - "object_type": MCPToolSchema.string(String(localized: "Object type, for a table match")), + "schema": MCPToolSchema.nullableString( + String(localized: "Schema the match is in, null on an engine without schemas") + ), + "object_type": MCPToolSchema.string( + String(localized: "Object type, such as TABLE or VIEW, for a table match") + ), "data_type": MCPToolSchema.string(String(localized: "Column type, for a column match")) ], - required: ["kind", "name"] + required: ["kind", "name", "schema"] ) ), - "is_truncated": MCPToolSchema.boolean(String(localized: "Whether the limit clipped the matches")) + "is_truncated": MCPToolSchema.boolean(String(localized: "Whether the limit clipped the matches")), + "unlisted_schemas": MCPToolSchema.array( + String(localized: "Schemas whose tables could not be listed, so a match in them may be missing"), + of: MCPToolSchema.string(String(localized: "Schema name")) + ), + "column_search": MCPToolSchema.string( + String(localized: "Whether columns were searched, or left out because the table matches reached the limit or the column read failed"), + enumValues: MCPSchemaSearch.ColumnSearch.outcomes + ), + "columns_schema": MCPToolSchema.nullableString( + String(localized: "Schema whose columns were searched, when they were") + ) ], - required: ["term", "matches", "is_truncated"] + required: ["term", "database", "schema", "matches", "is_truncated", "unlisted_schemas", "column_search"] ) public init() {} @@ -514,11 +536,13 @@ public struct SearchSchemaTool: MCPToolImplementation { ) let term = try MCPArgumentDecoder.requireNonEmptyString(arguments, key: "term") let limit = try MCPArgumentDecoder.optionalInt(arguments, key: "limit", range: 1...500) ?? 50 + let namedSchema = try MCPArgumentDecoder.optionalString(arguments, key: "schema") ?? "" let scope = try await MCPScopeArguments.resolve(arguments, services: services) let payload = try await services.connectionBridge.searchSchema( scope: scope, term: term, - limit: limit + limit: limit, + schemaIsNamed: !namedSchema.isEmpty ) return .structured(payload) } diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index b36d3d9693..76896bfa52 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -193,8 +193,13 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen return columnsToReturn[table.lowercased()] ?? [] } + var fetchAllColumnsError: Error? + func fetchAllColumns() async throws -> [String: [ColumnInfo]] { fetchAllColumnsCallCount += 1 + if let fetchAllColumnsError { + throw fetchAllColumnsError + } return allColumnsToReturn } diff --git a/TableProTests/Core/MCP/MCPSchemaSearchTests.swift b/TableProTests/Core/MCP/MCPSchemaSearchTests.swift new file mode 100644 index 0000000000..5bc74b74c5 --- /dev/null +++ b/TableProTests/Core/MCP/MCPSchemaSearchTests.swift @@ -0,0 +1,322 @@ +// +// MCPSchemaSearchTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("search_schema reach") +@MainActor +struct MCPSchemaSearchTests { + private struct ReadFailed: Error {} + + private let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: "public") + + private func table(_ name: String, _ schema: String, _ type: TableInfo.TableType = .table) -> TableInfo { + TestFixtures.makeTableInfo(name: name, type: type, schema: schema) + } + + private func request( + _ term: String, + limit: Int = 50, + reach: MCPSchemaSearch.TableReach = .everySchema(excluding: ["pg_catalog"]) + ) -> MCPSchemaSearch.Request { + MCPSchemaSearch.Request(scope: scope, term: term, limit: limit, tableReach: reach) + } + + private func search( + _ request: MCPSchemaSearch.Request, + on driver: MockDatabaseDriver + ) async throws -> MCPSchemaSearch.Result { + try await MCPSchemaSearch.run(request, metadata: SchemaSearchMetadataProvider(driver: driver)) + } + + @Test("A search that names no schema finds a table in another schema") + func findsATableInAnotherSchema() async throws { + let driver = MockDatabaseDriver() + driver.schemaTablesToReturn = ["public": [table("users", "public")]] + driver.allSchemaTablesToReturn = [table("users", "public"), table("timesheet", "attendance")] + + let result = try await search(request("timesheet"), on: driver) + + #expect(result.matches == [.table(name: "timesheet", schema: "attendance", type: .table)]) + #expect(driver.fetchTablesInAllSchemasCallCount == 1) + #expect(driver.fetchSchemaTablesCalls.isEmpty) + } + + @Test("Views and other table-like objects in other schemas keep their kind") + func viewsKeepTheirKind() async throws { + let driver = MockDatabaseDriver() + driver.allSchemaTablesToReturn = [ + table("timesheet_summary", "reporting", .view), + table("timesheet_totals", "reporting", .materializedView) + ] + + let result = try await search(request("timesheet"), on: driver) + + #expect(result.matches == [ + .table(name: "timesheet_summary", schema: "reporting", type: .view), + .table(name: "timesheet_totals", schema: "reporting", type: .materializedView) + ]) + } + + @Test("Naming a schema searches that schema alone") + func namingASchemaNarrowsTheSearch() async throws { + let driver = MockDatabaseDriver() + driver.schemaTablesToReturn = ["public": [table("users", "public")]] + driver.allSchemaTablesToReturn = [table("users", "public"), table("timesheet", "attendance")] + + let result = try await search(request("timesheet", reach: .scopeSchema), on: driver) + + #expect(result.matches.isEmpty) + #expect(driver.fetchTablesInAllSchemasCallCount == 0) + #expect(driver.fetchSchemaTablesCalls == ["public"]) + } + + @Test("Without a single call for every schema, each schema is listed through the one scope") + func perSchemaFallbackUsesTheOwnerListing() async throws { + let driver = MockDatabaseDriver() + driver.schemasToReturn = ["public", "attendance", "pg_catalog"] + driver.schemaTablesToReturn = [ + "public": [table("users", "public")], + "attendance": [table("timesheet", "attendance")], + "pg_catalog": [table("pg_timesheet", "pg_catalog")] + ] + let metadata = SchemaSearchMetadataProvider(driver: driver) + + let result = try await MCPSchemaSearch.run(request("timesheet"), metadata: metadata) + + #expect(result.matches == [.table(name: "timesheet", schema: "attendance", type: .table)]) + #expect(driver.fetchSchemaTablesCalls == ["public", "attendance"]) + #expect(Set(metadata.requestedScopes) == [scope]) + } + + @Test("A schema whose tables could not be listed is named in the result") + func unlistedSchemaIsReported() async throws { + let driver = MockDatabaseDriver() + driver.schemasToReturn = ["public", "payroll", "attendance"] + driver.schemaTablesToReturn = [ + "public": [table("users", "public")], + "attendance": [table("timesheet", "attendance")] + ] + driver.schemaTablesErrors = ["payroll": ReadFailed()] + + let result = try await search(request("timesheet"), on: driver) + + #expect(result.matches == [.table(name: "timesheet", schema: "attendance", type: .table)]) + #expect(result.unlistedSchemas == ["payroll"]) + } + + @Test("Tables in the current schema lead, then the other schemas in name order") + func currentSchemaLeads() async throws { + let driver = MockDatabaseDriver() + driver.allSchemaTablesToReturn = [ + table("users", "attendance"), + table("users", "zeta"), + table("user_roles", "public"), + table("users", "public") + ] + + let result = try await search(request("user"), on: driver) + + #expect(result.matches == [ + .table(name: "user_roles", schema: "public", type: .table), + .table(name: "users", schema: "public", type: .table), + .table(name: "users", schema: "attendance", type: .table), + .table(name: "users", schema: "zeta", type: .table) + ]) + } + + @Test("Columns come from the schema the driver is on, and each one names it") + func columnsNameTheirSchema() async throws { + let driver = MockDatabaseDriver() + driver.currentSchema = "public" + driver.allSchemaTablesToReturn = [table("users", "public"), table("timesheet", "attendance")] + driver.allColumnsToReturn = ["users": [TestFixtures.makeColumnInfo(name: "email", dataType: "text")]] + + let result = try await search(request("email"), on: driver) + + #expect(result.matches == [.column(name: "email", table: "users", schema: "public", dataType: "text")]) + #expect(result.columnSearch == .searched(schema: "public")) + } + + @Test("Table matches past the limit clip the result without reading columns") + func tableMatchesPastTheLimit() async throws { + let driver = MockDatabaseDriver() + driver.allSchemaTablesToReturn = [table("log_a", "public"), table("log_b", "audit"), table("log_c", "audit")] + driver.allColumnsToReturn = ["log_a": [TestFixtures.makeColumnInfo(name: "log_id")]] + + let result = try await search(request("log", limit: 2), on: driver) + + #expect(result.matches.count == 2) + #expect(result.isTruncated) + #expect(result.columnSearch == .limitReached) + #expect(driver.fetchAllColumnsCallCount == 0) + } + + @Test("Exactly as many matches as the limit is not a truncated result") + func exactlyTheLimitIsNotTruncated() async throws { + let driver = MockDatabaseDriver() + driver.allSchemaTablesToReturn = [table("log_a", "public"), table("log_b", "audit")] + + let exact = try await search(request("log", limit: 2), on: driver) + #expect(exact.matches.count == 2) + #expect(!exact.isTruncated) + #expect(exact.columnSearch == .searched(schema: "public")) + + driver.allColumnsToReturn = ["log_a": [TestFixtures.makeColumnInfo(name: "log_id")]] + let over = try await search(request("log", limit: 2), on: driver) + #expect(over.matches.count == 2) + #expect(over.isTruncated) + } + + @Test("A column read that fails keeps the table matches and says so") + func failedColumnReadIsReported() async throws { + let driver = MockDatabaseDriver() + driver.allSchemaTablesToReturn = [table("timesheet", "attendance")] + driver.fetchAllColumnsError = ReadFailed() + + let result = try await search(request("timesheet"), on: driver) + + #expect(result.matches == [.table(name: "timesheet", schema: "attendance", type: .table)]) + #expect(result.columnSearch == .failed) + #expect(!result.isTruncated) + } + + @Test("A lost connection fails the search rather than reading as no match") + func lostConnectionFailsTheSearch() async { + let driver = MockDatabaseDriver() + driver.allSchemaTablesError = DatabaseError.notConnected + + await #expect(throws: DatabaseError.self) { + try await search(request("timesheet"), on: driver) + } + } + + @Test("Only an unnamed schema on an engine that lists tables per schema reaches every schema") + func reachFollowsTheEngineAndTheArguments() { + let system: Set = ["pg_catalog", "information_schema"] + for grouping in [GroupingStrategy.bySchema, .hierarchicalSchema] { + #expect( + MCPSchemaSearch.tableReach(schemaIsNamed: false, grouping: grouping, systemSchemas: system) + == .everySchema(excluding: system) + ) + #expect( + MCPSchemaSearch.tableReach(schemaIsNamed: true, grouping: grouping, systemSchemas: system) + == .scopeSchema + ) + } + for grouping in [GroupingStrategy.flat, .byDatabase] { + #expect( + MCPSchemaSearch.tableReach(schemaIsNamed: false, grouping: grouping, systemSchemas: system) + == .scopeSchema + ) + } + } +} + +@Suite("search_schema payload") +struct MCPSchemaSearchPayloadTests { + private let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: "public") + + private func encode(_ result: MCPSchemaSearch.Result, schemaIsNamed: Bool = false) -> JsonValue { + MCPConnectionBridge.encode(search: result, term: "time", scope: scope, schemaIsNamed: schemaIsNamed) + } + + @Test("Every match carries its schema, null on an engine without schemas") + func everyMatchCarriesItsSchema() throws { + let payload = encode(MCPSchemaSearch.Result( + matches: [ + .table(name: "timesheet", schema: "attendance", type: .view), + .column(name: "started_at", table: "shifts", schema: "public", dataType: "timestamp"), + .table(name: "clock", schema: nil, type: .table) + ], + isTruncated: false, + unlistedSchemas: [], + columnSearch: .searched(schema: "public") + )) + + let matches = try #require(payload["matches"]?.arrayValue) + #expect(matches.count == 3) + #expect(matches[0]["schema"]?.stringValue == "attendance") + #expect(matches[0]["object_type"]?.stringValue == "VIEW") + #expect(matches[1]["schema"]?.stringValue == "public") + #expect(matches[1]["table"]?.stringValue == "shifts") + #expect(matches[2]["schema"]?.isNull == true) + } + + @Test("An unnamed schema is echoed as null, a named one as itself") + func schemaEcho() { + let result = MCPSchemaSearch.Result(matches: [], isTruncated: false, unlistedSchemas: [], columnSearch: .failed) + #expect(encode(result)["schema"]?.isNull == true) + #expect(encode(result, schemaIsNamed: true)["schema"]?.stringValue == "public") + #expect(encode(result)["database"]?.stringValue == "shop") + } + + @Test("Unlisted schemas and the column outcome reach the caller") + func partialCoverageIsReported() { + let searched = encode(MCPSchemaSearch.Result( + matches: [], + isTruncated: false, + unlistedSchemas: ["payroll"], + columnSearch: .searched(schema: "public") + )) + #expect(searched["unlisted_schemas"]?.arrayValue?.compactMap(\.stringValue) == ["payroll"]) + #expect(searched["column_search"]?.stringValue == "searched") + #expect(searched["columns_schema"]?.stringValue == "public") + + let clipped = encode(MCPSchemaSearch.Result( + matches: [], + isTruncated: true, + unlistedSchemas: [], + columnSearch: .limitReached + )) + #expect(clipped["unlisted_schemas"]?.arrayValue?.isEmpty == true) + #expect(clipped["column_search"]?.stringValue == "limit_reached") + #expect(clipped["columns_schema"] == nil) + + let failed = encode(MCPSchemaSearch.Result(matches: [], isTruncated: false, unlistedSchemas: [], columnSearch: .failed)) + #expect(failed["column_search"]?.stringValue == "failed") + #expect(failed["columns_schema"] == nil) + } + + @Test("The tool declares every schema-wide field it returns") + func toolSchemaDeclaresThePayload() throws { + let input = SearchSchemaTool.inputSchema + #expect(input["required"]?.arrayValue?.compactMap(\.stringValue) == ["connection_id", "term"]) + #expect(input["properties"]?["schema"]?["description"]?.stringValue?.contains("every schema") == true) + + let output = try #require(SearchSchemaTool.outputSchema) + let required = output["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + #expect(required.contains("unlisted_schemas")) + #expect(required.contains("column_search")) + let columnSearch = output["properties"]?["column_search"]?["enum"]?.arrayValue?.compactMap(\.stringValue) + #expect(columnSearch == ["searched", "limit_reached", "failed"]) + let item = try #require(output["properties"]?["matches"]?["items"]) + #expect(item["required"]?.arrayValue?.compactMap(\.stringValue).contains("schema") == true) + } +} + +@MainActor +private final class SchemaSearchMetadataProvider: ScopedMetadataProviding { + private let driver: MockDatabaseDriver + private(set) var requestedScopes: [DatabaseScope] = [] + + init(driver: MockDatabaseDriver) { + self.driver = driver + } + + func withMetadataDriver( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + requestedScopes.append(scope) + return try await body(driver) + } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { nil } +} diff --git a/docs/external-api/mcp-resources.mdx b/docs/external-api/mcp-resources.mdx index 45d0e0c2bd..f8aaf96d2b 100644 --- a/docs/external-api/mcp-resources.mdx +++ b/docs/external-api/mcp-resources.mdx @@ -96,7 +96,7 @@ The payload is a JSON string inside `text`. The shapes on the rest of this page } ``` -Capped at 100 tables. Beyond that the payload also carries `truncated: true` and `total_tables`. For a larger schema use the `list_tables` tool, or `search_schema` to find one column. +Capped at 100 tables. Beyond that the payload also carries `truncated: true` and `total_tables`. For a larger schema use the `list_tables` tool, or `search_schema` to find a table in any schema or a column by name. This is the only subscribable resource. See [Subscriptions](/external-api/mcp-subscriptions). diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index 56e03bf343..9585f16e15 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -10,7 +10,7 @@ If a dedicated tool covers the job, it beats hand-written SQL. It quotes identif In the tables below, required arguments come first and optional ones follow in parentheses. Unless a row says otherwise: - `connection_id` is a connection UUID from `list_connections`. -- `database` and `schema` default to whatever the connection is currently browsing. Passing them targets somewhere else **without moving the app's selection**. +- `database` and `schema` default to whatever the connection is currently browsing, except that `search_schema` without `schema` looks in every schema. Passing them targets somewhere else **without moving the app's selection**. - An unknown argument is rejected with `-32602`. There is no silent ignoring. - The result comes back twice: as JSON text in `content[0]`, and as a typed object in `structuredContent`. Read `structuredContent`. - Timestamps are ISO 8601, error text is redacted, and "a result set" means `columns[]`, `rows[][]`, `row_count`, `rows_affected`, `execution_time_ms`, `is_truncated`, plus `status_message`, `database` and `schema` when they apply. @@ -67,7 +67,7 @@ The two `switch_` tools move what the user sees in TablePro. To run one statemen | `list_tables` | `connection_id` (`database`, `schema`, `include_row_counts`) | `tables[]` (`name`, `type`, `schema`, `comment`, `row_count`), `database`, `schema`, `row_counts_included`, `row_counts_are_approximate` | | `describe_table` | `connection_id`, `table` (`database`, `schema`) | `table`, `database`, `schema`, `columns[]`, `indexes[]`, `foreign_keys[]`, `ddl`, `approximate_row_count` | | `get_table_ddl` | `connection_id`, `table` (`database`, `schema`) | `table`, `schema`, `ddl` | -| `search_schema` | `connection_id`, `term` (`limit`, `database`, `schema`) | `term`, `matches[]` (`kind` is `table` or `column`, plus `name`, `table`, `schema`, `object_type`, `data_type`), `is_truncated`. Table matches first | +| `search_schema` | `connection_id`, `term` (`limit`, `database`, `schema`) | `term`, `database`, `schema`, `matches[]` (`kind` is `table` or `column`, plus `name`, `schema`, `table`, `object_type`, `data_type`), `is_truncated`, `unlisted_schemas[]`, `column_search`, `columns_schema` | | `list_indexes` | `connection_id` (`table`, `database`, `schema`) | `database`, `schema`, `tables[]` of `{ table, indexes[] }`. Tables with no index are left out | | `list_foreign_keys` | `connection_id` (`table`, `database`, `schema`) | `database`, `schema`, `tables[]` of `{ table, foreign_keys[] }` | | `list_triggers` | `connection_id` (`table`, `database`, `schema`) | `triggers[]` (`name`, `table`, `schema`, `timing`, `event`, `orientation`, `statement`, `definition`, `is_enabled`), sorted by table then name, plus `table` when one was named | @@ -82,7 +82,20 @@ The two `switch_` tools move what the user sees in TablePro. To run one statemen `include_row_counts` defaults to `false`. Counts come from engine statistics rather than `COUNT(*)`, and are fetched one table at a time, so `list_tables` skips them when the schema holds more than 200 objects. -`search_schema` locates a column without describing every table; its `limit` runs 1 to 500, default 50. `list_routines` takes `kind` as `procedure` or `function`, omitted for both, and its `signature` is the argument list, not the return type. `list_triggers` takes `table` to scope to one table, omitted for every trigger in the schema. `list_types` takes `kind` as `enum`, `composite`, `domain` or `range`, omitted for all four; `labels` is set for an enum, `fields` for a composite and `base_type` for a domain or range, and only PostgreSQL and PGlite answer with rows. +`list_routines` takes `kind` as `procedure` or `function`, omitted for both, and its `signature` is the argument list, not the return type. `list_triggers` takes `table` to scope to one table, omitted for every trigger in the schema. `list_types` takes `kind` as `enum`, `composite`, `domain` or `range`, omitted for all four; `labels` is set for an enum, `fields` for a composite and `base_type` for a domain or range, and only PostgreSQL and PGlite answer with rows. + +### `search_schema` + +Leave out `schema` to ask where something lives. Tables, views and other table-like objects are then searched in every schema of the database except the system ones such as `pg_catalog`, and columns in the current schema alone. Name a schema to search only that one, its columns included. On an engine without schemas, such as MySQL or SQLite, the search covers the whole database. + +Table matches come first, those in the current schema ahead of the rest, then column matches. Every match carries its `schema`, `null` on an engine without schemas. `limit` runs 1 to 500, default 50, and `is_truncated` is set only when more matches existed than it allowed. + +Two fields report what the search could not cover: + +| Field | Meaning | +|-------|---------| +| `unlisted_schemas` | Schemas whose tables could not be read, usually for lack of privileges. A table in one of them is missing from `matches` | +| `column_search` | `searched`, with `columns_schema` naming the schema. `limit_reached` when table matches alone filled `limit`. `failed` when the columns could not be read, leaving table matches only | ## Reading data From eca90f11730404bc7e8b11aac9eccd46526ce812 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 23 Sep 2026 23:33:56 +0700 Subject: [PATCH 2/2] fix(mcp): fail search_schema on a lost connection and take the column schema from the driver --- .../Core/MCP/MCPConnectionBridge+Data.swift | 2 +- TablePro/Core/MCP/MCPSchemaSearch.swift | 39 +++++++------------ .../Protocol/Tools/MCPScopeArguments.swift | 10 ++++- .../Protocol/Tools/SchemaObjectTools.swift | 6 +-- .../Core/MCP/MCPSchemaSearchTests.swift | 39 +++++++++++++++++++ docs/external-api/mcp-tools.mdx | 2 + 6 files changed, 68 insertions(+), 30 deletions(-) diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift index 3afd64f966..72930e2eac 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift @@ -195,7 +195,7 @@ extension MCPConnectionBridge { "matches": .array(result.matches.map(encode(match:))), "is_truncated": .bool(result.isTruncated), "unlisted_schemas": .array(result.unlistedSchemas.map(JsonValue.string)), - "column_search": .string(result.columnSearch.outcome) + "column_search": .string(result.columnSearch.outcome.rawValue) ] if case .searched(let schema) = result.columnSearch { payload["columns_schema"] = nullable(schema) diff --git a/TablePro/Core/MCP/MCPSchemaSearch.swift b/TablePro/Core/MCP/MCPSchemaSearch.swift index 46431c5f73..1abd6f029d 100644 --- a/TablePro/Core/MCP/MCPSchemaSearch.swift +++ b/TablePro/Core/MCP/MCPSchemaSearch.swift @@ -7,17 +7,6 @@ import Foundation import os import TableProPluginKit -/// What `search_schema` finds for one term. -/// -/// A caller that names no schema is asking where something lives, so on an engine whose tables -/// live in schemas the tables and views come from every one of them, through the same listing Open -/// Quickly and the sidebar filter search. It is read fresh on every call rather than taken from the -/// sidebar's copy, which only learns of catalog changes the app makes itself: a table a migration -/// created from a terminal would stay invisible to the tool until the next reconnect. -/// -/// Columns come from one schema either way. Every schema's columns would be a catalog read of the -/// whole database for each search, so the result names the schema they came from, and a caller -/// looks in another by naming it. internal enum MCPSchemaSearch { internal enum TableReach: Equatable, Sendable { case scopeSchema @@ -36,18 +25,22 @@ internal enum MCPSchemaSearch { case column(name: String, table: String, schema: String?, dataType: String) } + internal enum ColumnSearchOutcome: String, CaseIterable, Sendable { + case searched + case limitReached = "limit_reached" + case failed + } + internal enum ColumnSearch: Equatable, Sendable { case searched(schema: String?) case limitReached case failed - internal static let outcomes = [Self.searched(schema: nil), .limitReached, .failed].map(\.outcome) - - internal var outcome: String { + internal var outcome: ColumnSearchOutcome { switch self { - case .searched: "searched" - case .limitReached: "limit_reached" - case .failed: "failed" + case .searched: .searched + case .limitReached: .limitReached + case .failed: .failed } } } @@ -80,12 +73,11 @@ internal enum MCPSchemaSearch { internal static func run(_ request: Request, metadata: ScopedMetadataProviding) async throws -> Result { let needle = request.term.lowercased() let listing = try await tables(reaching: request.tableReach, in: request.scope, metadata: metadata) - let listedSchema = request.tableReach == .scopeSchema ? request.scope.schema : nil let tableMatches = ordered( listing.tables.filter { $0.name.lowercased().contains(needle) }, preferring: request.scope.schema ).map { table in - Match.table(name: table.name, schema: table.schema ?? listedSchema, type: table.type) + Match.table(name: table.name, schema: table.schema, type: table.type) } let unlisted = listing.unlistedSchemas.sorted() @@ -104,6 +96,8 @@ internal enum MCPSchemaSearch { columnRead = try await columns(matching: needle, in: request.scope, metadata: metadata) } catch is CancellationError { throw CancellationError() + } catch let error as DatabaseError { + throw error } catch { logger.warning("[search] column read failed error=\(error.publicLogShape, privacy: .public)") return Result( @@ -121,8 +115,6 @@ internal enum MCPSchemaSearch { ) } - /// The schema the caller is on leads, the way it wins ties in Open Quickly, so a limit that - /// clips the matches clips other schemas' first. internal static func ordered(_ tables: [TableInfo], preferring schema: String?) -> [TableInfo] { let sorted = MCPConnectionBridge.sortedTables(tables) guard let schema else { return sorted } @@ -151,9 +143,8 @@ internal enum MCPSchemaSearch { in scope: DatabaseScope, metadata: ScopedMetadataProviding ) async throws -> ColumnRead { - let fallbackSchema = scope.schema - return try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in - let schema = (driver as? SchemaSwitchable)?.currentSchema ?? fallbackSchema + try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in + let schema = (driver as? SchemaSwitchable)?.currentSchema let allColumns = try await driver.fetchAllColumns() var matches: [Match] = [] for table in allColumns.keys.sorted() { diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPScopeArguments.swift b/TablePro/Core/MCP/Protocol/Tools/MCPScopeArguments.swift index df84b32851..b45a3ff37f 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPScopeArguments.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPScopeArguments.swift @@ -17,11 +17,17 @@ enum MCPScopeArguments { services: MCPToolServices ) async throws -> DatabaseScope { let database = try MCPArgumentDecoder.optionalString(arguments, key: "database") - let schema = try MCPArgumentDecoder.optionalString(arguments, key: "schema") return try await services.connectionBridge.resolveScope( connectionId: connectionId, database: database, - schema: schema + schema: try namedSchema(arguments) ) } + + static func namedSchema(_ arguments: JsonValue) throws -> String? { + guard let schema = try MCPArgumentDecoder.optionalString(arguments, key: "schema"), !schema.isEmpty else { + return nil + } + return schema + } } diff --git a/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift b/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift index f84fdefc0e..8c26a2c1d8 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift @@ -514,7 +514,7 @@ public struct SearchSchemaTool: MCPToolImplementation { ), "column_search": MCPToolSchema.string( String(localized: "Whether columns were searched, or left out because the table matches reached the limit or the column read failed"), - enumValues: MCPSchemaSearch.ColumnSearch.outcomes + enumValues: MCPSchemaSearch.ColumnSearchOutcome.allCases.map(\.rawValue) ), "columns_schema": MCPToolSchema.nullableString( String(localized: "Schema whose columns were searched, when they were") @@ -536,13 +536,13 @@ public struct SearchSchemaTool: MCPToolImplementation { ) let term = try MCPArgumentDecoder.requireNonEmptyString(arguments, key: "term") let limit = try MCPArgumentDecoder.optionalInt(arguments, key: "limit", range: 1...500) ?? 50 - let namedSchema = try MCPArgumentDecoder.optionalString(arguments, key: "schema") ?? "" + let namedSchema = try MCPScopeArguments.namedSchema(arguments) let scope = try await MCPScopeArguments.resolve(arguments, services: services) let payload = try await services.connectionBridge.searchSchema( scope: scope, term: term, limit: limit, - schemaIsNamed: !namedSchema.isEmpty + schemaIsNamed: namedSchema != nil ) return .structured(payload) } diff --git a/TableProTests/Core/MCP/MCPSchemaSearchTests.swift b/TableProTests/Core/MCP/MCPSchemaSearchTests.swift index 5bc74b74c5..8f73d96b0c 100644 --- a/TableProTests/Core/MCP/MCPSchemaSearchTests.swift +++ b/TableProTests/Core/MCP/MCPSchemaSearchTests.swift @@ -160,6 +160,7 @@ struct MCPSchemaSearchTests { @Test("Exactly as many matches as the limit is not a truncated result") func exactlyTheLimitIsNotTruncated() async throws { let driver = MockDatabaseDriver() + driver.currentSchema = "public" driver.allSchemaTablesToReturn = [table("log_a", "public"), table("log_b", "audit")] let exact = try await search(request("log", limit: 2), on: driver) @@ -186,6 +187,33 @@ struct MCPSchemaSearchTests { #expect(!result.isTruncated) } + @Test("Columns on an engine without schemas name no schema") + func columnsWithoutASchema() async throws { + let driver = MockDatabaseDriver() + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] + driver.allColumnsToReturn = ["users": [TestFixtures.makeColumnInfo(name: "email", dataType: "varchar")]] + let flatScope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) + + let result = try await MCPSchemaSearch.run( + MCPSchemaSearch.Request(scope: flatScope, term: "email", limit: 50, tableReach: .scopeSchema), + metadata: SchemaSearchMetadataProvider(driver: driver) + ) + + #expect(result.matches == [.column(name: "email", table: "users", schema: nil, dataType: "varchar")]) + #expect(result.columnSearch == .searched(schema: nil)) + } + + @Test("A connection lost during the column read fails the search rather than dropping the columns") + func lostConnectionDuringColumnReadFailsTheSearch() async { + let driver = MockDatabaseDriver() + driver.allSchemaTablesToReturn = [table("timesheet", "attendance")] + driver.fetchAllColumnsError = DatabaseError.notConnected + + await #expect(throws: DatabaseError.self) { + try await search(request("timesheet"), on: driver) + } + } + @Test("A lost connection fails the search rather than reading as no match") func lostConnectionFailsTheSearch() async { let driver = MockDatabaseDriver() @@ -220,6 +248,17 @@ struct MCPSchemaSearchTests { @Suite("search_schema payload") struct MCPSchemaSearchPayloadTests { + @Test("A blank schema is not a named one, and any other string is") + func namedSchemaFollowsTheScope() throws { + #expect(try MCPScopeArguments.namedSchema(.object([:])) == nil) + #expect(try MCPScopeArguments.namedSchema(.object(["schema": .null])) == nil) + #expect(try MCPScopeArguments.namedSchema(.object(["schema": .string("")])) == nil) + #expect(try MCPScopeArguments.namedSchema(.object(["schema": .string("attendance")])) == "attendance") + #expect(throws: MCPProtocolError.self) { + try MCPScopeArguments.namedSchema(.object(["schema": .int(1)])) + } + } + private let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: "public") private func encode(_ result: MCPSchemaSearch.Result, schemaIsNamed: Bool = false) -> JsonValue { diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index 9585f16e15..bf1a4b592a 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -97,6 +97,8 @@ Two fields report what the search could not cover: | `unlisted_schemas` | Schemas whose tables could not be read, usually for lack of privileges. A table in one of them is missing from `matches` | | `column_search` | `searched`, with `columns_schema` naming the schema. `limit_reached` when table matches alone filled `limit`. `failed` when the columns could not be read, leaving table matches only | +A lost connection, or a database where no schema could be read, fails the call instead of returning part of an answer. + ## Reading data | Tool | Arguments | Returns |