Skip to content

feat(mcp): search every schema from search_schema when no schema is named - #3097

Merged
datlechin merged 2 commits into
mainfrom
feat/mcp-search-schema-all-schemas
Sep 23, 2026
Merged

datlechin merged 2 commits into
mainfrom
feat/mcp-search-schema-all-schemas

Conversation

@datlechin

Copy link
Copy Markdown
Member

Follow-up to #3060 (#3048).

The gap

search_schema listed one schema: the one named, or the one the connection was browsing. On a PostgreSQL database browsing public, an AI client asking where the timesheet table is got "matches": [] for attendance.timesheet. That is the gap #3048 reported for Open Quickly and the sidebar filter, which #3060 closed for the UI only.

What changed

  • No schema argument, per-schema engine (PostgreSQL family, DuckDB, SQL Server, SurrealDB, and the hierarchical engines such as Oracle, Snowflake, Trino, BigQuery): tables, views and other table-like objects come from every schema through CatalogTableListing, the listing feat(sidebar): find tables in every schema from Open Quickly and the sidebar filter #3060 built, less the engine's system schemas (pg_catalog, information_schema, pg_toast on PostgreSQL). PostgreSQL answers in one query (fetchTablesInAllSchemas); the others are listed schema by schema through the one metadata scope, the owner's own fallback. No new per-schema loop.
  • schema named: that schema alone, columns included, as before.
  • Engine without schemas (MySQL, SQLite and the other .flat / .byDatabase engines): unchanged, the whole database.
  • Table matches lead with the current schema, then the other schemas in name order, so a limit clips other schemas first.
  • Every match now carries schema (null on an engine without schemas). Column matches never did; their schema is the one the driver actually read (currentSchema), not the requested one.
  • is_truncated was true whenever the matches reached the limit exactly. It is now set only when more matches existed.
  • New result fields: database, schema (the one named, else null), unlisted_schemas, column_search, columns_schema.

Object kinds

Kind Without schema With schema
Table, view, materialized view, foreign table, partitioned table Every schema, with object_type as list_tables reports it That schema
Column The current schema only, named in columns_schema That schema
Routine, type, trigger Not searched, before or after: list_routines, list_types, list_triggers cover them per schema Same

Columns stay in one schema because a column read of every schema is a whole-catalog read per search: a per-schema column loop is what this change is not allowed to add, and no driver has a one-call all-schema column read. The result says which schema the columns came from, so a client narrows to another by naming it.

Why the listing is read fresh, not from the sidebar's copy

DatabaseTreeMetadataService holds the listing stale-while-revalidate, marked stale only by catalog changes the app itself sees. A table created by a migration the agent ran in a terminal would stay invisible to the tool until a reconnect. The tree service also swallows errors into a string, while this tool has to return not_connected and the driver's own error, and an MCP call naming another database would populate and redraw sidebar state for a database the user never opened. So the tool calls CatalogTableListing.tables(in:excludingSchemas:metadata:) through DatabaseManager.withMetadataDriver with the scope it already resolved: one query per call on PostgreSQL, the same cost the old fetchTables had.

Policy and partial results

  • Authorization is unchanged and still runs before the tool, in ToolsCallHandler: token scope tools:read, connection allowlist, AI policy Never, External Clients Blocked, and the approval prompt. The search reads the catalog of the one connection and database it resolved, never another connection. Nothing reaches the statement gate, so read-only and Safe Mode are not involved, as before.
  • Argument validation is unchanged: unknown keys refused, term non-empty, limit 1 to 500 (default 50), schema and database strings. "Was a schema named" is one decode, MCPScopeArguments.namedSchema, which scope resolution now uses too, so a blank schema means not named for both.
  • A schema whose table read failed (usually privileges) comes back in unlisted_schemas rather than reading as "no match there". A lost connection, or every schema failing, fails the call, as CatalogTableListing already decides, and a connection lost during the column read fails it too.
  • The column read used to fail silently (try?). It still does not fail the call, since the table matches are worth returning, but column_search now says failed. When table matches alone exceed the limit, columns are not read and column_search says limit_reached.
  • The one new log line records the error's shape only (publicLogShape), never schema names, the term or the message.

Tests

MCPSchemaSearchTests and MCPSchemaSearchPayloadTests (new, Swift Testing, 19 cases) drives MCPSchemaSearch.run through a recording ScopedMetadataProviding over MockDatabaseDriver, and the payload encoder directly. The MCP tool harness uses a live MCPConnectionBridge with no driver seam, so the argument checks stay in MCPCatalogToolTests.

  • A search that names no schema finds a table in another schema is the regression test. Its fixture answers fetchTables(schema: "public") with users alone, which is the read the old code made, so pointing the table read back at fetchTables(schema: scope.schema) turns it red. Naming a schema searches that schema alone runs exactly that old read on the same fixture and asserts it finds nothing, which is the before half on record. No separate mutation run was made.
  • Also covered: views keep their kind, the per-schema fallback reads every non-system schema through one scope, unlisted schemas are reported, current schema first, columns name their schema (and no schema on an engine without them), limit clipping, exact limit is not truncated, a failed column read, a connection lost during the table or the column read, the reach decision per grouping strategy, blank versus named schema, and the output schema.

Verdicts:

  • verify.sh build: PASS.
  • verify.sh test MCPSchemaSearchTests MCPSchemaSearchPayloadTests MCPEnumeratedArgumentTests CatalogTableListingTests ListTablesToolTests SQLSchemaProviderTests: PASS, 74 executed, 74 passed, 19 of them in the new suites. An earlier run reported an Xcode build-service crash (environment), and the rerun passed.
  • verify.sh lint on the six changed Swift files: 0 violations.
  • verify.sh docs: PASS.
  • Built and tested before the rebase onto origin/main. The rebase conflicted only in CHANGELOG.md, and none of the files this change depends on moved.
  • The local build linked the Homebrew SQLite 3.53.4 arm64 archive as libsqlite3_vendored.a, because the published libs archive would not finish downloading here. That lib only affects the SQLite plugin, which this change does not touch.

Reviews

  • Skill(security-review): no findings. It checked that the search reaches the same connection and database through the same scope, that it reads only schemas list_schemas and list_tables already expose (system schemas now excluded), that it returns metadata and no row data, that term never reaches SQL, that authorization in ToolsCallHandler is untouched, and that the new log line carries the error shape only.
  • Skill(code-review) (Codex is unavailable until Sep 29): 9 findings. Fixed in the second commit:
    • A connection lost during the column read was reported as column_search: failed. It now fails the call, matching CatalogTableListing.
    • The column schema fell back to the requested scope when the driver reported none. It is now the driver's own currentSchema, so an engine without schemas reports null.
    • Type-level doc comments removed, per CLAUDE.md.
    • The column_search vocabulary is now a CaseIterable enum rather than a hand-kept list.
    • A redundant schema fallback is gone: the adapter already fills it.
    • The schema argument is decoded once for both the scope and the search.
  • Three code-review findings were dismissed:
    • The column read still runs when table matches exactly fill the limit. Every search below the limit pays the same read. Skipping it would make is_truncated guess, and a wrong true costs the client a second call.
    • Engines other than PostgreSQL list schema by schema on each call. That is CatalogTableListing's own fallback, queued per schema on the metadata lane. Reusing the sidebar's cached listing is the stale-data trade-off described above.
    • Columns depend on which schema a pooled connection was left on. Pool entries are keyed by the full scope, schema included (MetadataConnectionPool.acquireEntry, Key(scope:workload:)), so a scope without a schema never shares a driver that was switched.

Docs: docs/external-api/mcp-tools.mdx (table row, a new search_schema section, the defaults note) and docs/external-api/mcp-resources.mdx.

@mintlify

mintlify Bot commented Sep 23, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 23, 2026, 4:35 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@datlechin
datlechin merged commit 0328f8f into main Sep 23, 2026
10 of 11 checks passed
@datlechin
datlechin deleted the feat/mcp-search-schema-all-schemas branch September 23, 2026 19:13

This branch was successfully deployed

1 active deployment
staging - docs eca90f11 Deployed Sep 23, 2026 by mintlify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant