Most database MCP servers are wrong. Here is what a good one looks like.
Most MCP servers for databases expose one tool: run_sql(query). That makes the LLM a single point of failure between your prompt and your data. Here is the pattern I keep seeing, three failure modes I have run into, and the four properties a database MCP server actually needs.
By Leonidas Jeremy, founder of QueryDeck. 1,800 words. About 8 min.
The single-tool run_sql MCP pattern is fine for personal sandboxes and dangerous for anything else. A database MCP server worth pointing at real data needs four properties: narrow typed tools, project context (your ORM schema), explicit gating on writes with a hardware factor, and per-connection scope with environment tagging. The rest of this essay is the why.
When Anthropic released the Model Context Protocol in late 2024, the first wave of database MCP servers was predictable. Take psycopg2, take pg8000, take the mysql connector, wrap them in a single MCP tool called run_sql, ship. The shape is satisfying. One tool, infinite power, the LLM does the rest.
I built one of these myself in an afternoon, the week MCP shipped. It is the same shape you get from copy-pasting any of the popular database MCP examples on GitHub. Connection string in env, run_sql tool that proxies to the driver, return rows as JSON. Plug it into Claude Desktop, demo the “just ask your data” flow, post on X.
And it works. The first conversation with Claude about my staging database felt like magic. It listed tables, described columns, ran joins. I was sold. Then I tried to use it for real work, against connections I actually cared about, and the three failure modes below all happened within a week.
The DROP TABLE that almost shipped
First time I wired a one-tool MCP server to a Postgres staging DB, I asked Cursor to "clean up the test users from yesterday". It generated a DELETE FROM users WHERE created_at > '2026-04-01' and called run_sql. No confirmation. No diff. The query ran against the wrong column (the test users had a flag column, not a timestamp), and I watched the row count come back as something close to my entire user table. Staging, thankfully. But the pattern is the pattern: any tool surface that takes arbitrary SQL is one model misread away from destructive action.
The model invents a column name and you cannot tell
Schema-blind MCP servers force the model to guess at column names. It will often guess right, especially for boring schemas. But when it guesses wrong (deleted_at vs is_deleted, user_id vs userId, status vs state), the SELECT just returns zero rows. The model interprets the empty result as a real answer ("no users match") and you make a decision based on a bug it could not detect. A schema-aware tool surface fixes this at the source: list_tables, describe_table, and run_select with a typed schema argument all surface the real shape before the query runs.
Cross-database leakage
If your MCP server takes a connection string at config time and exposes one run_sql tool, the model has no way to know which database it is talking to. Ask it about "orders" while connected to your analytics replica and it will happily query the wrong shape. Worse, if the connection has multi-schema access (a common Postgres role pattern), a generic run_sql can read across schemas the model never knew existed. The fix is per-connection MCP server instances, each scoped to a single named database, with the connection name baked into every tool call.
Narrow tools, not one big SQL hatch
Expose typed tools: list_tables, describe_table, get_row, run_select, count_where, explain_plan, list_indexes, find_foreign_keys. Each tool has a clear contract. The model picks the right one for the job and you can audit every call. A single run_sql tool collapses all of this into trust-the-LLM.
Project context, not just a connection string
If your project has a Prisma or Drizzle schema, the MCP server should know it. The model should be told (via the tool descriptions or a project_context tool) that the User model has these fields with these types, that orders.user_id is a foreign key, that there is an enum for OrderStatus. Without that, the model is reverse-engineering your schema from list_tables on every conversation.
Writes are gated. Always.
Reads can be free. Writes (INSERT, UPDATE, DELETE, DDL) need a separate tool, a human-readable confirmation surface, and a hardware factor (Touch ID, YubiKey, whatever you have). Not a config flag the model can flip. Not a per-prompt approval the model can bypass with a clever wrapper. A separate tool that physically waits for you.
Per-connection scope, with environment tagging
One MCP server instance per database connection. The model literally cannot see the wrong DB. Production connections light up red across the UI and inject a banner in every read response so the model knows. Staging stays separate. Cross-environment queries are not a thing, by construction.
Below is a short read of the four shapes I see when someone says “database MCP server”. I am not naming products on purpose. The same shape shows up under many names and the criticism is structural, not personal. If your favorite database MCP fits one of these and you want to push back, write me at leonidas@tryhard.be.
One-tool run_sql
A single tool that takes arbitrary SQL and runs it. Usually advertised as "give the model the keys."
Fails all four properties. Reads are unbounded, writes are not gated, scope leaks, no schema context.
Fine for a personal sandbox. Do not point it at anything you cannot afford to lose.
Read-only run_sql wrapper
Same shape as above, but the server refuses any non-SELECT. Often built as a quick proxy in a few lines.
Solves the write problem. Still gives the model unbounded read scope, no schema context, no per-connection tagging.
Safer floor but the model is still guessing column names every conversation.
ORM-backed RPC server
Generates one tool per model in your ORM. Sometimes typed with the ORM schema. Reads only.
Good on schema context. Often locked to a single ORM. Writes still tend to be allow-all or block-all, no per-call gating.
Strong for read-heavy AI agents in one stack. Weak for the cross-stack tool QueryDeck wanted to be.
Project-aware database client (the QueryDeck shape)
Narrow typed tools, schema context lifted from the ORM if present, writes behind Touch ID, per-connection scope, environment tagging built in.
Heavier to build. Requires the database tooling to actually parse the project, not just take a connection string.
What I shipped because I want to leave the MCP server running while I sleep.
QueryDeck's MCP server is the “project-aware database client” row of the table above. It exposes around a dozen narrow tools instead of a single run_sql. It reads your Prisma or Drizzle schema if it can find one in the project (Drift Mode parsers cover six ORMs today), and injects that context into the tool descriptions so Claude or Cursor knows your column shapes before the first query runs.
Writes go through a separate run_write tool that requires Touch ID per call and surfaces a diff modal in the QueryDeck UI. Production-tagged connections light up red across the app and inject a banner in every read response. Each connection spawns its own MCP server instance, so the model literally cannot ask about a database you did not point it at.
This is heavier to build than the one-tool wrapper, which is exactly the trade I want to make once and for all my customers, not five times by five different people who all hit Failure 1 in different orders. If you want the setup walk-through, the QueryDeck Postgres MCP server page has the config block and the example flows.
Postgres MCP server: config and flows
The 5-line MCP config for Claude Code and Cursor, the tool surface, and four example flows from real conversations.
See the setup →What schema drift looks like, and how Drift Mode catches it
The class of bug that motivates project-aware tooling. Real Prisma and Drizzle examples, real postmortems.
Read the deep dive →Try the MCP server I would actually trust in prod.
The database client that knows your project. $79 one-time. All your Macs.