Your "Read-Only" Database MCP Probably Isn't

Connecting an AI agent to your database over MCP almost always comes with a reassuring promise: read-only mode. The agent can look but not touch. No DROP, no DELETE, no surprises.

It sounds airtight. It usually isn’t — and the gap is wider than most teams realize. A query that never contains a single write keyword can still read files off your database server, open outbound network connections, or hang the database entirely. All of it inside a perfectly ordinary-looking SELECT.

How “read-only” usually gets enforced — and why it leaks

Most read-only guards work one of two ways, and both have the same blind spot.

The keyword check. Block any statement containing INSERT, UPDATE, DELETE, DROP, ALTER, etc. If none of those appear and the query starts with SELECT, allow it.

The statement-type check. Parse the SQL, look at the top-level statement type, allow it if it’s a SELECT.

Both ask the same question — “is this a write statement?” — and a SELECT answers “no.” But that’s the wrong question, because the danger isn’t always the statement type. It’s the functions the statement calls. Consider:

SELECT pg_read_file('/etc/passwd');

That’s a SELECT. It contains no write keyword. A keyword guard waves it through; a type-only parser sees a read and waves it through too. And it returns the contents of a file on your database server.

This isn’t theoretical. A read-only-bypass of exactly this shape was disclosed in a popular open-source Postgres MCP server in mid-2026 (CWE-863 → CWE-22): the allowlist only inspected top-level function calls, so a file-reading function placed in the FROM clause slipped past and disclosed arbitrary server files.

The functions that turn a SELECT into a weapon

Every major database ships read-path functions that do far more than read rows. A few examples by dialect:

DatabaseFunction in a SELECTWhat it does
PostgreSQLpg_read_file('/etc/passwd')Read any server file
PostgreSQLdblink('host=…','…')Open a connection to another database
PostgreSQLpg_sleep(60)Stall the connection (DoS)
MySQLload_file('/etc/passwd')Read a server file
MySQLsleep(60) / benchmark(…)Stall / burn CPU
SQL Serverxp_cmdshell('whoami')Run an OS command
SQL Serveropenrowset(…)Read external files / data sources
Oracleutl_file / utl_httpFile access / outbound HTTP

None of these are writes. All of them pass a guard that only asks “is this a write?” An agent — or anyone who can influence the agent’s prompt — can reach them through an ordinary query.

What a real read-only layer has to do

Blocking writes is necessary but not sufficient. A read-only enforcement layer that you’d actually trust an agent with needs to:

  1. Strip comments and string/identifier literals before inspecting — so -- DROP TABLE in a comment or 'please delete this' in a string doesn’t false-positive, and an attacker can’t hide a keyword inside a literal.
  2. Reject multi-statement inputSELECT 1; DROP TABLE users is two statements; a read-only connection should accept exactly one.
  3. Block dangerous functions by name, wherever they appear — not just at the top level. File-read, OS-exec, cross-database, and sleep/DoS functions must be denied even inside a valid SELECT, a CTE, or a subquery.
  4. Default to deny — anything that isn’t an unambiguous single read statement is rejected, not “allowed because it didn’t look like a write.”
  5. Keep least-privilege at the database too — the connection itself should use a role that can’t read files or write data, so the guard is the first layer, not the only one.

The theme: a read-only layer should reason about what the query can do, not just what kind of statement it is.

How AI2SQL handles it

AI2SQL’s agent query layer is read-only by default, and the guard does exactly the above. It sanitizes comments and literals, allows a single SELECT/WITH read and nothing else, and blocks the server-side file-read, OS-exec, cross-database, and DoS functions across PostgreSQL, MySQL, SQL Server, and Oracle — even when they’re tucked inside an otherwise valid SELECT. Every query that runs is logged.

If you’re letting an agent query production data, “read-only” is only as good as what it actually blocks. Ask any tool — including the one you build yourself — a simple test: does SELECT pg_read_file('/etc/passwd') get through? If it does, your read-only mode isn’t.

FAQ

Is a SELECT statement always safe to run read-only? No. A SELECT can call functions that read server files (pg_read_file, load_file), open network or cross-database connections (dblink, utl_http), run OS commands (xp_cmdshell), or stall the server (pg_sleep). Statement type alone doesn’t make a query safe.

Why doesn’t blocking write keywords (INSERT/UPDATE/DELETE) make a connection read-only? Because the risky operations are function calls inside reads, not write statements. A guard that only looks for write keywords passes SELECT pg_read_file(...) unchanged.

How should I test my own database MCP’s read-only mode? Try a SELECT that calls a file-read or sleep function for your dialect (e.g. SELECT pg_read_file('/etc/passwd') on Postgres, SELECT load_file('/etc/passwd') on MySQL). If it executes, the read-only layer is incomplete.

Does read-only mode replace database permissions? No — it complements them. Use a least-privilege database role and a statement guard. Defense in depth means a bypass in one layer doesn’t expose your data.