# Database MCP Server: Should an AI Agent Run SQL or Only Read the Schema?

> Source: <https://dev.to/tbson87/database-mcp-server-should-an-ai-agent-run-sql-or-only-read-the-schema-4nap>
> Published: 2026-10-01 01:25:24+00:00

*Disclosure: I build [Schemity](https://schemity.com), a desktop ERD tool - this post is from our blog and uses it for the examples.*

**TL;DR:** For schema work an AI agent needs the structure, not the rows, so give it a database MCP server that exposes the schema and no SQL tool. When a task does need rows, guard it with a database role that owns nothing. A `READ ONLY` transaction alone is not a guard: the reference Postgres MCP server relied on one, and a single `COMMIT; DROP TABLE` sent as one query ended it.

An AI agent doing schema work needs the structure of your database, not its rows, so the safest database MCP server for it is one that exposes the schema and has no SQL tool at all. When a task really does need data, the guard has to be the database's own permissions: a login role that owns nothing. A read-only transaction around the agent's SQL looks like the same guard, and it is not.

The difference is easy to show. The reference Postgres MCP server, which the Model Context Protocol project published as an example, runs every query inside `BEGIN TRANSACTION READ ONLY`. Every test below was run against PostgreSQL 18.3 in a throwaway container, with that tool's handler reproduced on the same `pg` driver (version 8.23.0) and a `customers` table holding emails and card digits.

The options differ in what the agent sees and in what stops a write:

| What the MCP server offers | What the agent can read | What stops a write | 
|---|---|---|
| A SQL tool inside a `READ ONLY` transaction, as your usual login | Every row | The transaction flag, which the agent's own SQL can end | 
| A SQL tool, logged in as a role with `SELECT` only | Every row it has `SELECT` on | The role's privileges | 
| A SQL tool, logged in as a role with no table grants | Structure only, from `pg_catalog` | The role's privileges | 
| No SQL tool, only schema tools | Structure, plus whatever counts the tools compute | There is no statement to write with | 

The first row is the one most "read-only" database MCP servers start from, and it is the only one where the agent can undo the guard.

Not when the transaction is the only guard. The [archived reference server's source](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) handles a query in three calls: `client.query("BEGIN TRANSACTION READ ONLY")`, then `client.query(sql)` with the agent's text, then `ROLLBACK`. With no parameters, node-postgres sends that text over the simple query protocol, which accepts several statements in one string. Sent through the same three calls:

`SELECT email, card_last4 FROM customers` returned every customer's email and card digits.`INSERT INTO customers ...` failed with `cannot execute INSERT in a read-only transaction`, as intended.` COMMIT; DROP TABLE customers` returned `COMMIT` and `DROP`. The `relation "customers" does not exist`.
This is not a new finding: Datadog Security Labs described the same escape in ["MCP vulnerability case study: SQL injection in the Postgres MCP server"](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/) on August 21, 2025. The repository was archived on May 29, 2025, and its README now says "No security updates or bug fixes will be provided" for these servers. Other Postgres MCP servers are separate code, so read how yours runs the agent's SQL: a prepared statement accepts only one statement, while a raw query string passed through as it arrives accepts several.

Even without the escape, the first result is the larger problem for most teams. Whatever a tool returns becomes part of the agent's context, so with a cloud-hosted model the emails and card digits are now with the model provider too.

The database checks privileges on every statement, whatever transaction it runs in, so writes to your tables are refused however the agent's SQL is shaped. The same three calls, logged in as a role with `pg_read_all_data` and a default timeout:

```
CREATE ROLE agent_ro LOGIN PASSWORD 'change-me';
GRANT pg_read_all_data TO agent_ro;
ALTER ROLE agent_ro SET statement_timeout = '5s';
```

`must be owner of table customers`, and the table stayed.` SELECT pg_sleep(10)` failed after 5 seconds with `canceling statement due to statement timeout`, but only because the SQL did not change it. `SET LOCAL statement_timeout = 0; SELECT pg_sleep(3)` ran to the end: a role's timeout is a default, and any role can override it. A hard limit has to sit where the agent's SQL cannot reach, in the MCP server or a connection pooler.`COMMIT; CREATE TEMP TABLE t (x int)` succeeded, because `PUBLIC` may create temporary tables by default. That touches none of your data; revoke `TEMP` on the database if it matters.
So a `SELECT`-only role fixes the write escape but not the data exposure. For an agent that works on the schema rather than the data, go one step further: a role with `CONNECT` on the database, `USAGE` on the schema and no table grants reads the entire structure from `pg_catalog` and is refused every row. Only from `pg_catalog`, though: `information_schema` hides every table the role has no privilege on, so a SQL tool that lists tables through it shows that role an empty database. That setup, and what the catalogue still reveals, is in [a PostgreSQL role that reads the schema but not the data](https://schemity.com/blog/postgres-role-read-schema-not-data/).

Most of what an agent is asked to do with a database is structural: explain how tables relate, find where a module's tables are, propose a new table or a column change, or check what a migration will break. None of that needs a row. It needs table and column names, types, nullability, keys, check constraints, indexes and comments, and for judging a change, row estimates and table sizes, which the catalogue holds too.

That is what to understand your schema means for an agent, and it is also the least sensitive part of the database to send to a model. A diagram of `customers` with an `email` column tells the model the column exists. A `SELECT` tells it every address.

Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. It is also a local database MCP server with 16 tools, and none of them runs SQL the agent writes: `analyze_migration_file` accepts a migration file to analyse, and it is parsed, never executed. The agent reads the schema Schemity already holds and never receives the database credentials. [Connecting Claude Code](https://schemity.com/doc/ai-assisted-design/) takes one command, and other MCP hosts take a config entry.

What the tools give an agent:

`get_schema` returns the tables and views with their columns, keys, constraints, indexes and relations. `get_dependencies` lists the relations into and out of one table, `get_context_views` the domains the tables are grouped into, and `get_data_dictionary` the same schema as a document with its descriptions.`propose_changes` edits the diagram. The edits land as unsaved changes you review, and nothing is written to the database.`analyze_impact` reports what the pending migration would cost, and returns the SQL only when asked, with a parameter description telling the agent that Schemity never runs it.`count_rows` returns one number, such as how many rows of a column are `NULL` before it becomes `NOT NULL`. It takes a probe object rather than SQL and refuses the one kind of probe built from free text, so its query is assembled from quoted table and column names with no text from the agent in it. That, not the transaction, is what keeps agent SQL out; the read-only transaction and the 60-second timeout it also runs under on PostgreSQL are set by Schemity, where the agent cannot change them. It is a full table scan, so ask your agent to run it only when you want the number.
Every transport listens on `127.0.0.1` only and needs a token, and the HTTP one also checks the request's origin. Applying a migration stays a human action in the app. Connect the diagram with the no-grants role from the post above and `count_rows` is refused too, which leaves the agent with nothing but structure.

`SELECT` on the tables it needs and owning nothing, a time limit the agent cannot unset, and only where sending those rows to your model provider is acceptable.`READ ONLY` transaction as the only protection.
For what the agent should be allowed to change once it has read the schema, see [should AI agents write database migrations](https://schemity.com/blog/should-ai-agents-write-database-migrations/). Using the agent to sort a large legacy schema into domains is covered in [grouping a legacy database by domain](https://schemity.com/blog/reverse-engineer-legacy-database-group-by-domain/).
