Skip to content

Commit 0328f8f

Browse files
authored
feat(mcp): search every schema from search_schema when no schema is named (#3097)
* feat(mcp): search every schema from search_schema when no schema is named * fix(mcp): fail search_schema on a lost connection and take the column schema from the driver
1 parent 9d188f2 commit 0328f8f

9 files changed

Lines changed: 643 additions & 53 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6767
- **Show Previous Window Tab** and **Show Next Window Tab** for window tabs, with no default shortcut.
6868
- SQLite 3.53.4 built into the SQLite and libSQL drivers in place of the macOS copy.
6969
- One-time reset of Open Quickly's Recent query history, and of its objects on connections that switch databases.
70+
- Tables and views from every schema in the MCP `search_schema` tool when no schema is named. (#3048)
7071

7172
### Removed
7273

‎TablePro/Core/MCP/MCPConnectionBridge+Data.swift‎

Lines changed: 57 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -166,45 +166,65 @@ extension MCPConnectionBridge {
166166
return pagination.clampedRowCount(request.limit)
167167
}
168168

169-
func searchSchema(scope: DatabaseScope, term: String, limit: Int) async throws -> JsonValue {
170-
try await ensureConnected(scope.connectionId)
171-
let schema = scope.schema
172-
let needle = term.lowercased()
173-
174-
let matches = try await DatabaseManager.shared.withMetadataDriver(
175-
scope: scope,
176-
workload: .bulk
177-
) { driver -> [JsonValue] in
178-
let tables = MCPConnectionBridge.sortedTables(try await driver.fetchTables(schema: schema))
179-
var found: [JsonValue] = []
180-
for table in tables where table.name.lowercased().contains(needle) {
181-
found.append(.object([
182-
"kind": .string("table"),
183-
"name": .string(table.name),
184-
"object_type": .string(table.type.rawValue),
185-
"schema": table.schema.map(JsonValue.string) ?? JsonValue.null
186-
]))
187-
if found.count >= limit { return found }
188-
}
189-
let allColumns = (try? await driver.fetchAllColumns()) ?? [:]
190-
for tableName in allColumns.keys.sorted() {
191-
for column in allColumns[tableName] ?? [] where column.name.lowercased().contains(needle) {
192-
found.append(.object([
193-
"kind": .string("column"),
194-
"name": .string(column.name),
195-
"table": .string(tableName),
196-
"data_type": .string(column.dataType)
197-
]))
198-
if found.count >= limit { return found }
199-
}
200-
}
201-
return found
169+
func searchSchema(scope: DatabaseScope, term: String, limit: Int, schemaIsNamed: Bool) async throws -> JsonValue {
170+
let databaseType = try await ensureConnected(scope.connectionId)
171+
let tableReach = await MainActor.run {
172+
MCPSchemaSearch.tableReach(
173+
schemaIsNamed: schemaIsNamed,
174+
grouping: PluginManager.shared.databaseGroupingStrategy(for: databaseType),
175+
systemSchemas: Set(PluginManager.shared.systemSchemaNames(for: databaseType))
176+
)
202177
}
203-
return .object([
178+
let result = try await MCPSchemaSearch.run(
179+
MCPSchemaSearch.Request(scope: scope, term: term, limit: limit, tableReach: tableReach),
180+
metadata: DatabaseManager.shared
181+
)
182+
return Self.encode(search: result, term: term, scope: scope, schemaIsNamed: schemaIsNamed)
183+
}
184+
185+
static func encode(
186+
search result: MCPSchemaSearch.Result,
187+
term: String,
188+
scope: DatabaseScope,
189+
schemaIsNamed: Bool
190+
) -> JsonValue {
191+
var payload: [String: JsonValue] = [
204192
"term": .string(term),
205-
"matches": .array(matches),
206-
"is_truncated": .bool(matches.count >= limit)
207-
])
193+
"database": .string(scope.database),
194+
"schema": schemaIsNamed ? nullable(scope.schema) : .null,
195+
"matches": .array(result.matches.map(encode(match:))),
196+
"is_truncated": .bool(result.isTruncated),
197+
"unlisted_schemas": .array(result.unlistedSchemas.map(JsonValue.string)),
198+
"column_search": .string(result.columnSearch.outcome.rawValue)
199+
]
200+
if case .searched(let schema) = result.columnSearch {
201+
payload["columns_schema"] = nullable(schema)
202+
}
203+
return .object(payload)
204+
}
205+
206+
static func encode(match: MCPSchemaSearch.Match) -> JsonValue {
207+
switch match {
208+
case .table(let name, let schema, let type):
209+
return .object([
210+
"kind": .string("table"),
211+
"name": .string(name),
212+
"schema": nullable(schema),
213+
"object_type": .string(type.rawValue)
214+
])
215+
case .column(let name, let table, let schema, let dataType):
216+
return .object([
217+
"kind": .string("column"),
218+
"name": .string(name),
219+
"table": .string(table),
220+
"schema": nullable(schema),
221+
"data_type": .string(dataType)
222+
])
223+
}
224+
}
225+
226+
private static func nullable(_ value: String?) -> JsonValue {
227+
value.map(JsonValue.string) ?? .null
208228
}
209229

210230
func insertRows(
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//
2+
// MCPSchemaSearch.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
import TableProPluginKit
9+
10+
internal enum MCPSchemaSearch {
11+
internal enum TableReach: Equatable, Sendable {
12+
case scopeSchema
13+
case everySchema(excluding: Set<String>)
14+
}
15+
16+
internal struct Request: Sendable {
17+
internal let scope: DatabaseScope
18+
internal let term: String
19+
internal let limit: Int
20+
internal let tableReach: TableReach
21+
}
22+
23+
internal enum Match: Equatable, Sendable {
24+
case table(name: String, schema: String?, type: TableInfo.TableType)
25+
case column(name: String, table: String, schema: String?, dataType: String)
26+
}
27+
28+
internal enum ColumnSearchOutcome: String, CaseIterable, Sendable {
29+
case searched
30+
case limitReached = "limit_reached"
31+
case failed
32+
}
33+
34+
internal enum ColumnSearch: Equatable, Sendable {
35+
case searched(schema: String?)
36+
case limitReached
37+
case failed
38+
39+
internal var outcome: ColumnSearchOutcome {
40+
switch self {
41+
case .searched: .searched
42+
case .limitReached: .limitReached
43+
case .failed: .failed
44+
}
45+
}
46+
}
47+
48+
internal struct Result: Equatable, Sendable {
49+
internal let matches: [Match]
50+
internal let isTruncated: Bool
51+
internal let unlistedSchemas: [String]
52+
internal let columnSearch: ColumnSearch
53+
}
54+
55+
private struct ColumnRead: Sendable {
56+
let schema: String?
57+
let matches: [Match]
58+
}
59+
60+
private static let logger = Logger(subsystem: "com.TablePro", category: "MCPSchemaSearch")
61+
62+
internal static func tableReach(
63+
schemaIsNamed: Bool,
64+
grouping: GroupingStrategy,
65+
systemSchemas: Set<String>
66+
) -> TableReach {
67+
guard !schemaIsNamed, DatabaseTreeMetadataService.listsTablesPerSchema(grouping) else {
68+
return .scopeSchema
69+
}
70+
return .everySchema(excluding: systemSchemas)
71+
}
72+
73+
internal static func run(_ request: Request, metadata: ScopedMetadataProviding) async throws -> Result {
74+
let needle = request.term.lowercased()
75+
let listing = try await tables(reaching: request.tableReach, in: request.scope, metadata: metadata)
76+
let tableMatches = ordered(
77+
listing.tables.filter { $0.name.lowercased().contains(needle) },
78+
preferring: request.scope.schema
79+
).map { table in
80+
Match.table(name: table.name, schema: table.schema, type: table.type)
81+
}
82+
let unlisted = listing.unlistedSchemas.sorted()
83+
84+
guard tableMatches.count <= request.limit else {
85+
return Result(
86+
matches: Array(tableMatches.prefix(request.limit)),
87+
isTruncated: true,
88+
unlistedSchemas: unlisted,
89+
columnSearch: .limitReached
90+
)
91+
}
92+
93+
let room = request.limit - tableMatches.count
94+
let columnRead: ColumnRead
95+
do {
96+
columnRead = try await columns(matching: needle, in: request.scope, metadata: metadata)
97+
} catch is CancellationError {
98+
throw CancellationError()
99+
} catch let error as DatabaseError {
100+
throw error
101+
} catch {
102+
logger.warning("[search] column read failed error=\(error.publicLogShape, privacy: .public)")
103+
return Result(
104+
matches: tableMatches,
105+
isTruncated: false,
106+
unlistedSchemas: unlisted,
107+
columnSearch: .failed
108+
)
109+
}
110+
return Result(
111+
matches: tableMatches + columnRead.matches.prefix(room),
112+
isTruncated: columnRead.matches.count > room,
113+
unlistedSchemas: unlisted,
114+
columnSearch: .searched(schema: columnRead.schema)
115+
)
116+
}
117+
118+
internal static func ordered(_ tables: [TableInfo], preferring schema: String?) -> [TableInfo] {
119+
let sorted = MCPConnectionBridge.sortedTables(tables)
120+
guard let schema else { return sorted }
121+
return sorted.filter { $0.schema == schema } + sorted.filter { $0.schema != schema }
122+
}
123+
124+
private static func tables(
125+
reaching reach: TableReach,
126+
in scope: DatabaseScope,
127+
metadata: ScopedMetadataProviding
128+
) async throws -> CatalogTableListing.Result {
129+
switch reach {
130+
case .everySchema(let excluded):
131+
return try await CatalogTableListing.tables(in: scope, excludingSchemas: excluded, metadata: metadata)
132+
case .scopeSchema:
133+
let schema = scope.schema
134+
let tables = try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in
135+
try await driver.fetchTables(schema: schema)
136+
}
137+
return CatalogTableListing.Result(tables: tables, unlistedSchemas: [])
138+
}
139+
}
140+
141+
private static func columns(
142+
matching needle: String,
143+
in scope: DatabaseScope,
144+
metadata: ScopedMetadataProviding
145+
) async throws -> ColumnRead {
146+
try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in
147+
let schema = (driver as? SchemaSwitchable)?.currentSchema
148+
let allColumns = try await driver.fetchAllColumns()
149+
var matches: [Match] = []
150+
for table in allColumns.keys.sorted() {
151+
for column in allColumns[table] ?? [] where column.name.lowercased().contains(needle) {
152+
matches.append(.column(name: column.name, table: table, schema: schema, dataType: column.dataType))
153+
}
154+
}
155+
return ColumnRead(schema: schema, matches: matches)
156+
}
157+
}
158+
}

‎TablePro/Core/MCP/Protocol/Tools/MCPScopeArguments.swift‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,17 @@ enum MCPScopeArguments {
1717
services: MCPToolServices
1818
) async throws -> DatabaseScope {
1919
let database = try MCPArgumentDecoder.optionalString(arguments, key: "database")
20-
let schema = try MCPArgumentDecoder.optionalString(arguments, key: "schema")
2120
return try await services.connectionBridge.resolveScope(
2221
connectionId: connectionId,
2322
database: database,
24-
schema: schema
23+
schema: try namedSchema(arguments)
2524
)
2625
}
26+
27+
static func namedSchema(_ arguments: JsonValue) throws -> String? {
28+
guard let schema = try MCPArgumentDecoder.optionalString(arguments, key: "schema"), !schema.isEmpty else {
29+
return nil
30+
}
31+
return schema
32+
}
2733
}

‎TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -448,8 +448,9 @@ public struct SearchSchemaTool: MCPToolImplementation {
448448
public static let title: String? = String(localized: "Search Schema")
449449
public static let description = String(
450450
localized: """
451-
Find tables and columns whose name contains a substring, so a column can be located without \
452-
describing every table.
451+
Find tables, views and columns whose name contains a substring, and the schema each one is in. \
452+
Without 'schema', tables and views are searched in every schema and columns in the current \
453+
one; name a schema to search only that schema, columns included.
453454
"""
454455
)
455456
public static let requiredScopes: Set<MCPScope> = [.toolsRead]
@@ -471,16 +472,22 @@ public struct SearchSchemaTool: MCPToolImplementation {
471472
maximum: 500
472473
),
473474
"database": MCPToolSchema.database,
474-
"schema": MCPToolSchema.schema
475+
"schema": MCPToolSchema.string(
476+
String(localized: "Schema to search, columns included. Omit to search tables and views in every schema.")
477+
)
475478
],
476479
required: ["connection_id", "term"]
477480
)
478481

479482
public static let outputSchema: JsonValue? = MCPToolSchema.object(
480483
properties: [
481484
"term": MCPToolSchema.string(String(localized: "Term that was searched")),
485+
"database": MCPToolSchema.string(String(localized: "Database that was searched")),
486+
"schema": MCPToolSchema.nullableString(
487+
String(localized: "Schema the search was narrowed to, null when none was named")
488+
),
482489
"matches": MCPToolSchema.array(
483-
String(localized: "Matching tables first, then matching columns"),
490+
String(localized: "Matching tables and views first, those in the current schema leading, then matching columns"),
484491
of: MCPToolSchema.object(
485492
properties: [
486493
"kind": MCPToolSchema.string(
@@ -489,16 +496,31 @@ public struct SearchSchemaTool: MCPToolImplementation {
489496
),
490497
"name": MCPToolSchema.string(String(localized: "Matched name")),
491498
"table": MCPToolSchema.string(String(localized: "Owning table, for a column match")),
492-
"schema": MCPToolSchema.nullableString(String(localized: "Schema, for a table match")),
493-
"object_type": MCPToolSchema.string(String(localized: "Object type, for a table match")),
499+
"schema": MCPToolSchema.nullableString(
500+
String(localized: "Schema the match is in, null on an engine without schemas")
501+
),
502+
"object_type": MCPToolSchema.string(
503+
String(localized: "Object type, such as TABLE or VIEW, for a table match")
504+
),
494505
"data_type": MCPToolSchema.string(String(localized: "Column type, for a column match"))
495506
],
496-
required: ["kind", "name"]
507+
required: ["kind", "name", "schema"]
497508
)
498509
),
499-
"is_truncated": MCPToolSchema.boolean(String(localized: "Whether the limit clipped the matches"))
510+
"is_truncated": MCPToolSchema.boolean(String(localized: "Whether the limit clipped the matches")),
511+
"unlisted_schemas": MCPToolSchema.array(
512+
String(localized: "Schemas whose tables could not be listed, so a match in them may be missing"),
513+
of: MCPToolSchema.string(String(localized: "Schema name"))
514+
),
515+
"column_search": MCPToolSchema.string(
516+
String(localized: "Whether columns were searched, or left out because the table matches reached the limit or the column read failed"),
517+
enumValues: MCPSchemaSearch.ColumnSearchOutcome.allCases.map(\.rawValue)
518+
),
519+
"columns_schema": MCPToolSchema.nullableString(
520+
String(localized: "Schema whose columns were searched, when they were")
521+
)
500522
],
501-
required: ["term", "matches", "is_truncated"]
523+
required: ["term", "database", "schema", "matches", "is_truncated", "unlisted_schemas", "column_search"]
502524
)
503525

504526
public init() {}
@@ -514,11 +536,13 @@ public struct SearchSchemaTool: MCPToolImplementation {
514536
)
515537
let term = try MCPArgumentDecoder.requireNonEmptyString(arguments, key: "term")
516538
let limit = try MCPArgumentDecoder.optionalInt(arguments, key: "limit", range: 1...500) ?? 50
539+
let namedSchema = try MCPScopeArguments.namedSchema(arguments)
517540
let scope = try await MCPScopeArguments.resolve(arguments, services: services)
518541
let payload = try await services.connectionBridge.searchSchema(
519542
scope: scope,
520543
term: term,
521-
limit: limit
544+
limit: limit,
545+
schemaIsNamed: namedSchema != nil
522546
)
523547
return .structured(payload)
524548
}

‎TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,13 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen
193193
return columnsToReturn[table.lowercased()] ?? []
194194
}
195195

196+
var fetchAllColumnsError: Error?
197+
196198
func fetchAllColumns() async throws -> [String: [ColumnInfo]] {
197199
fetchAllColumnsCallCount += 1
200+
if let fetchAllColumnsError {
201+
throw fetchAllColumnsError
202+
}
198203
return allColumnsToReturn
199204
}
200205

0 commit comments

Comments
 (0)