mcp-multi-db connects an AI agent to PostgreSQL, MySQL and SQLite through one
server. Pointing something like that at real data only makes sense if writes
are impossible rather than discouraged, so read-only enforcement is the part of
the design worth writing down.
It is enforced twice, and the interesting part is that the two layers are not doing the same job. One produces good errors. The other is the boundary.
Why a tool description is not enough#
The obvious way to build this is to wrap the database client you already have and tell the model to behave — put only issue SELECT statements in the tool description and move on.
That fails for a reason worth being precise about. A tool description is an input to a probabilistic system. It shifts the distribution of outputs; it does not constrain the set of them. Treating it as an access control is the same category error as validating a form only in the browser.
It also fails quietly. The model will comply almost every time, so the logs look clean for weeks. What that measures is the common case, not the boundary.
Layer 1: the SQL-text guard#
validateReadOnlySql runs before anything reaches a database. It does four
things, in order:
validateReadOnly.ts
Reject multiple statements
Blocks stacked queries
One trailing semicolon is stripped; any remaining semicolon fails
Allowlist the opening keyword
Allowlist, not denylist
Only SELECT, WITH and EXPLAIN may start a query
Denylist mutating keywords
Belt to the allowlist's braces
INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, TRUNCATE, GRANT, CALL, COPY and more
Inject or clamp a LIMIT
Bounded results
Adds LIMIT 100 when absent; clamps an existing one to 1000
The opening check is an allowlist — SELECT, WITH, EXPLAIN, nothing
else. That is the load-bearing half. The keyword denylist that follows is the
belt to its braces: it catches mutations hiding later in an otherwise
well-formed read, such as a CTE that writes.
EXPLAIN returns early without a LIMIT, because appending one to a query
plan is meaningless.
The guard is a regex, and that is fine#
Layer 1 is string matching, and string matching against SQL is famously leaky. A regex cannot see that a keyword sits inside a string literal, so a perfectly legitimate query gets rejected:
SELECT id FROM tickets WHERE body LIKE '%DELETE ME%';That is a real limitation, and it is the correct trade only because layer 1 is not the security boundary. If it were, every gap in the pattern would be an exploit, and false negatives — the ones you never notice — would matter far more than false positives. Because it isn't, the guard can be deliberately conservative: it fails loudly and early with a readable error, and the cost of being wrong is an inconvenienced query rather than a mutated table.
Layer 2: the database refuses#
The guarantee comes from the connection, not the code above it. Each adapter prevents writes at the engine:
| Engine | Mechanism |
|---|---|
| PostgreSQL | Query runs inside BEGIN READ ONLY, always rolled back |
| MySQL | Query runs inside START TRANSACTION READ ONLY, always rolled back |
| SQLite | Connection opened with readonly: true and fileMustExist: true |
The case that motivates this is the one layer 1 structurally cannot catch: a
SELECT that calls a function with side effects. Nothing in the statement text
is a mutating keyword — the write happens inside the function body. The text
guard sees a clean read; the read-only transaction refuses it anyway.
This is why SECURITY.md states it plainly: a string-based filter alone is not
a security boundary, and layer 2 is what makes the read-only guarantee real.
Enforcement lives in code I did not write and cannot accidentally weaken.
The rollback is unconditional rather than reserved for errors, so no transaction state survives a query either way.
Bounded by default#
Two limits stop a single query becoming an incident.
Row count is capped in layer 1: LIMIT 100 by default, clamped to a maximum of
1000, injected when the model omits one and lowered when the model asks for
more. The result carries a truncated flag, so the agent knows it is looking at
a prefix and can narrow the query rather than confidently summarising the first
hundred rows as though they were all of them.
Statement timeouts are set per connection at 30 seconds on PostgreSQL and MySQL. MCP servers are long-lived and mostly idle, which is exactly the profile that surfaces connection problems, and a query that hangs past the transport timeout looks to the agent like a dead server rather than a slow query.
Four tools, not one#
The server exposes a graded surface rather than a single query escape hatch:
| Tool | Returns |
|---|---|
list_databases | Configured connections — id, type, label, description |
list_tables | Tables and views in one database |
describe_table | Columns for one table |
run_query | A read-only SELECT / WITH / EXPLAIN |
The first three exist so the agent can discover structure before writing SQL,
and list_databases is documented as the call to make first — every other tool
needs a database_id to target, since multiple engines are live at once.
The narrower tools also answer most questions on their own. A schema question
routed to describe_table costs nothing, cannot fail a validation check, and
does not consume the query budget.
What is left to the operator#
Two layers of enforcement still leave decisions outside the server's control,
and SECURITY.md names them rather than implying the defaults are sufficient:
- A dedicated database user with
GRANT SELECTonly. This is the strongest control available, and it is the one that holds even if a bug slips past both layers. It is a recommendation rather than a feature because the server cannot provision it for you. - Point at a read replica rather than a primary where the option exists.
- Scope what you connect. The agent can read every table its configured user can see; the server bounds what can be done, not what can be seen.
- Keep connection strings out of version control —
databases.jsonholds credentials and is gitignored for that reason.
That last group is the honest boundary of what a tool like this can promise. It can make writes impossible. It cannot decide which data an agent should be allowed to read, and pretending otherwise would be the more dangerous default.
The server is open source at
mcp-multi-db; the enforcement
described here is in src/sql/validateReadOnly.ts and the three adapters under
src/adapters/.