mcp-multi-db connects an AI agent to PostgreSQL, MySQL and SQLite through one MCP server (the Model Context Protocol, an open standard for giving AI applications tools and data). Since it shipped it has been read-only, enforced twice: a SQL-text guard, then a database connection that cannot write. The earlier post walks through both layers.

Read-only answers one question: can the agent change data? A security reviewer asks the next ones straight away. What can it read? Can it see personal data? Can I switch it off for one database? What did it actually run? Before v1.1, the answers were: every table the database user can see, yes, only by removing the database, and nothing was recorded.

v1.1.0 is on npm (npx mcp-multi-db), with release notes on GitHub. This post covers how v1.1 answers them, and the part that matters as much as the design: an independent attack pass that found two real bypasses before release.

The rule: ask the engine, not the text#

Every control that depends on what a query touches takes its answer from the database itself, never from parsing the SQL. Text can be aliased, quoted, wrapped in a CTE or hidden in a view. A query plan cannot, because the engine has already resolved all of that by the time it plans.

All of it is opt-in. A config with no policy behaves as before, and the 34 original tests pass unchanged.

ControlWhat it doesWhere the answer comes from
Tool scopeTurn tools off per database, e.g. schema-only access (excessive agency)The config, checked before any database call
Table allow/deny-listLimit which tables a query may readThe query plan, inside the read-only transaction, before the query runs
Column redactionReplace values like email with "[redacted]"Each result column's origin, as the driver reports it
Row, time and rate budgetsCap rows, statement time and queries per minuteServer-wide limits, narrowed per database
Audit logOne JSON line per tool call, never result valuesThe service, before the response is returned

Tables, from the plan#

On PostgreSQL, the server runs EXPLAIN (FORMAT JSON, VERBOSE) and walks the plan tree for every relation it scans. VERBOSE matters: it is what adds each scan's schema. Views are already expanded into their base tables, so a view over a denied table is refused like the table itself.

On SQLite, the server compiles the query with EXPLAIN and reads the bytecode. Every OpenRead names a root page, and sqlite_master maps each root page to its table. An index read counts as its table. Anything that cannot be mapped, such as a virtual table, an attached database or an unknown page, is refused.

MySQL was the honest exception. Its EXPLAIN reports table aliases, not table names, so FROM secrets AS accounts would look like a read of accounts. A table policy there could be bypassed by renaming, so instead of enforcing something unreliable, v1.1 refuses a MySQL table policy at startup and points the operator at MySQL grants.

Columns, from the driver#

Redaction keyed on the column name in the SQL is useless: SELECT email AS e walks straight past it. All three drivers report where each result column came from, whatever it was renamed to:

  • PostgreSQL: each field's tableID and columnID, resolved through pg_attribute
  • MySQL: orgTable and orgName
  • SQLite: better-sqlite3's statement.columns()

A computed column has no origin. lower(email) could carry a redacted value in another shape, so when a result contains an untraceable column and the plan reads a table holding a redacted column, the query is refused. That over-refuses some harmless queries, such as count(*) on that table, which is the right side to be wrong on.

Testing it like a reviewer would#

Each control has tests for the ways around it: an alias, a CTE, a subquery, a view, the schema table, a table-valued function, email AS e, SELECT *, lower(email), email || ''. Then each control was switched off in the built code, one at a time, to confirm its tests failed. A test that passes with the control removed proves nothing. PostgreSQL gets its own integration suite, which now runs in CI on every pull request against a real Postgres 16.

The weakness of that suite is that it was written alongside the code, so it covers the attacks its authors thought of. So before merging, a separate agent with no stake in the code was given one job: break the policy. It made 70 attempts against the SQLite build.

The table policy held against everything: views, CTEs, UNION, subqueries, quoted and upper-cased names, main.secrets, ORDER BY subqueries, and virtual tables. Origin-based redaction held against aliasing, duplicate column names, views and NATURAL JOIN. Then it found two real bypasses.

Bypass 1: the value was in the error message#

SELECT json_extract('{}', email) FROM accounts WHERE id = 1
-- Error: bad JSON path: 'ada@example.com'

SQLite's json_extract puts its argument into its error text. The error is raised while the query runs, so it never reaches redaction. Redaction works on results, and there was no result. The value went to the agent, and it also went into the audit log, which promises never to record values. A WHERE id = … made it targeted: one row's value per call, verbatim.

The fix: on a database with redacted columns, an error raised during execution is replaced with a generic message, for the caller and the audit log alike. Parse and plan errors happen before any row is read, so they still come through and the agent can fix a typo.

Bypass 2: the row cap trusted the LIMIT clause#

SELECT id FROM accounts LIMIT 0x1F4   -- read as 500

The text guard rewrote LIMIT with a regular expression that matched only leading digits. LIMIT 0x1F4 matched as LIMIT 0, was "clamped", and SQLite then read the hexadecimal. LIMIT 1e3 worked the same way. Following that thread turned up two more: the LIMIT 0, 500 offset-and-count form, and a query whose only LIMIT sits inside a subquery.

The fix has two layers, because one had just failed. A LIMIT must now be a plain integer, and anything else is refused with a clear message. More importantly, the adapters stop fetching at the cap themselves, whatever the SQL says. The SQL LIMIT is now an optimisation, not the guarantee.

Two leaks: one fixed, one stated#

Under a deny-list, SELECT sql FROM sqlite_master showed the denied table's name and its full DDL. The same gap existed in list_tables, describe_table and Postgres' information_schema. Now the system catalogs are refused under any table policy unless the allow-list names them, and the schema tools only show tables the policy allows.

The last finding is not fixed, because it is the edge of what redaction is.

SELECT name FROM accounts WHERE email LIKE 'a%'

No email comes back, but the query answers a question about one, and enough of them can reconstruct it. Redaction protects values in results. It does not stop a query filtering or sorting by a redacted column. Pretending otherwise would be the kind of claim this project exists to avoid. So it is written down in the README, SECURITY.md and the release notes, with the real fix: if inference matters, do not expose the column to the database user at all, and set a rate limit.

Every finding now has a regression test built from the attacker's exact query. The suite is at 90 tests, and the attack harness, re-run against the fixed build, returns no redacted value and no row-cap escape.

Lessons for anyone exposing data to agents#

  • Put the boundary where the engine decides. A plan or a driver's column-origin metadata cannot be talked around. A regular expression over SQL can, and the LIMIT bypass was exactly that.
  • Errors are an output channel. Redacting results while passing engine errors through verbatim is redacting one door and leaving another open.
  • Catalogs describe everything. A deny-list that still lets the agent read the schema of what it denies is half a policy.
  • Fail closed, and say why. Every refusal names the rule that fired, so the agent can rephrase and the audit log can explain the decision.
  • Test your own controls by switching them off, then have someone with no stake in the code attack them. The project's own tests passed. The attack pass found the two bugs that mattered.
  • State the limits you did not fix. A known gap, written down, is a decision an operator can make. An unstated one is a false promise.

If you are putting an AI agent in front of real data, a review of where your boundaries actually sit is work I take on. The approach is the one above: plan-level checks, origin-level redaction, and an attack pass before anything ships.

FAQ#

Is redaction by column name enough for an MCP database server? No. An alias such as SELECT email AS e bypasses it. mcp-multi-db redacts by each result column's origin, which PostgreSQL, MySQL and SQLite drivers all report, and refuses computed columns over tables that hold redacted columns.

Can an AI agent leak data through SQL error messages? Yes. Some database functions put their arguments into error text, and that text is raised before any result-level redaction runs. In mcp-multi-db v1.1, execution-time errors are withheld on databases with redacted columns.

Why not support a table allow-list on MySQL? MySQL's EXPLAIN reports table aliases rather than table names, so a plan-based table policy could be bypassed by renaming a table in the query. v1.1 refuses that configuration rather than enforce it unreliably.

References#