safe-db-mcp
Gives agents read-only SQL access to a MySQL database. Connects using environment-provided credentials and enforces a read-only session (SET SESSION TRANSACTION READ ONLY), MAX_EXECUTION_TIME and a socket read timeout, on top of a statement allow-list, row caps and PII masking. Exposes tools to list tables, describe a table's schema, and run guarded SELECT/SHOW/DESCRIBE/EXPLAIN queries, logging every call.
Gives agents read-only SQL access to a local SQLite database file, opened with mode=ro and PRAGMA query_only. Exposes tools to list tables, describe a table's schema, and run SELECT/SHOW/DESCRIBE/EXPLAIN queries that are parsed and allow-listed, capped with a pushed-down LIMIT, aborted on a timeout via a progress handler, and masked for configured PII columns, with every call written to an audit log.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@safe-db-mcplist the tables, then describe the orders schema"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
safe-db-mcp
Let an AI agent query your database without letting it break anything or leak anyone's data.
safe-db-mcp is a small Model Context Protocol server that gives an AI agent
read-only SQL access to SQLite or MySQL, behind production-style guardrails: a statement allow-list, a read-only
connection, row caps, query timeouts, PII masking and an audit log of every call. It works with any MCP app (Claude,
OpenAI Codex, Gemini CLI, Cursor, VS Code…), whichever model is behind it.
> Clean up cancelled orders
run_query({"sql": "DELETE FROM orders WHERE status = 'cancelled'"})
✗ Query rejected: DELETE statements are not allowed; only SELECT, SHOW, DESCRIBE and EXPLAIN are
> Show me a few customers
run_query({"sql": "SELECT id, full_name, email, phone, city FROM customers LIMIT 3"})
✓ [1, 'Kofi Evans', '[masked]', '[masked]', 'Springfield']Why this exists
Picture a new analyst on their first day. They're smart and fast, and they're very eager to help. You'd happily let them
ask the database questions. You would not give them the admin password, let them run DELETE "to tidy up", or let
them copy the customer phone list into a chat window.
An AI agent with a database tool is that analyst, with two differences. It works a hundred times faster, and anyone who can put text in front of it (a support ticket, a web page, a row in the table it just read) can try to talk it into something. A plain "run SQL" tool hands it the master key and hopes for the best.
I'm a backend engineer in fintech, where a wrong query can cost real money and a leaked phone number is a regulatory incident. So when an agent gets near production data, the question that matters isn't whether it's clever enough. It's what's the worst thing it can do on a bad day? This project is my answer, written from scratch as a generic, open-source version of that idea. No single check is trusted on its own. Each layer assumes the one before it might fail.
Related MCP server: MCP SQLite Server (Read-Only)
How it works
flowchart LR
A[Agent] -->|run_query sql| G1
subgraph server [safe-db-mcp]
G1[1 · Statement guard<br/>parse, allow-list] --> G2[2 · Row cap<br/>LIMIT pushed down]
G2 --> G3[3 · Read-only connection<br/>+ timeout]
G3 --> G4[4 · PII masking]
end
G3 <--> DB[(Database)]
G4 -->|rows| A
G1 -. rejected .-> L[(5 · Audit log)]
G4 -. allowed / error / timeout .-> L# | Layer | What it does | Why it's built this way |
1 | Statement guard ( | Parses the SQL into a syntax tree with sqlglot and allows exactly one | A keyword blacklist is a bouncer reading name tags, and |
2 | Row cap ( | Rewrites | Protects both the database and the agent's context window. The extra row is how we tell "exactly 200 rows" from "200 of a million". The rewritten SQL is generated from the same tree the guard checked, so what runs is what was checked. |
3 | Read-only connection + timeout ( | SQLite opens with | Belt and braces: even if a write slipped past layer 1, the database refuses it. The tests prove this by sending |
4 | Values in configured columns (e.g. | Masking by column name alone is easy to dodge: | |
5 | Audit log ( | One JSON line per tool call, whether it was allowed, rejected, failed or timed out. Each line has the original SQL, the SQL actually executed, the row count, the duration and the reason. The file is created owner-only ( | When someone asks "what did the agent look at last Tuesday?", there's an answer. |
6 | Credentials out of reach ( | Connection settings come from environment variables. The password comes from | No tool takes a host, user or password argument, so a prompt can't redirect the server to another database or extract the secret. |
Agents get three tools, each marked readOnlyHint: list_tables, describe_table(table_name) and run_query(sql).
Two more appear when you give the server a file of approved joins (see Approved joins).
Errors come back as plain sentences ("Query rejected: …", "Query timed out: … Narrow it with filters or LIMIT."), so the
agent can correct itself instead of retrying blindly.
Quickstart
Requires Python 3.10+ and uv.
git clone https://github.com/azam-ahmad-khan/safe-db-mcp.git
cd safe-db-mcp
uv syncBuild the sample database (a fictional outdoor-gear shop: 200 customers, 1,000 orders; every name, @example.com
address and 555 number is generated):
uv run python demo/build_sample_db.pyWatch a scripted agent session, with good questions, bad ones and the resulting audit log:
uv run python demo/walkthrough.pyConnect it to your AI assistant
It works with any app that speaks MCP, whichever model is behind it: Claude, GPT, Gemini, DeepSeek or a local model. Every app needs the same three things:
Setting | Value |
Command |
|
Arguments |
|
Environment |
|
The easy way: let your assistant set it up
If your assistant can edit its own settings (Claude Code, Codex CLI, Gemini CLI, Cursor, VS Code agent mode…), open it in this folder and paste:
Add a local (stdio) MCP server named "safe-db" to your MCP configuration.
Command: uv
Arguments: --directory <absolute path of this folder> run safe-db-mcp
Environment variables:
SAFE_DB_DIALECT=sqlite
SAFE_DB_SQLITE_PATH=<absolute path of this folder>/demo/shop.db
SAFE_DB_MASKED_COLUMNS=email,phone
Use your own config format, show me the change before you save it, then tell me how to reload so the
list_tables, describe_table and run_query tools show up.Then ask it something like "Which product category made the most money in March?" or "Delete the cancelled orders", and watch the second one get refused.
By hand
Most apps take the same JSON block:
{
"mcpServers": {
"safe-db": {
"command": "uv",
"args": ["--directory", "/absolute/path/to/safe-db-mcp", "run", "safe-db-mcp"],
"env": {
"SAFE_DB_DIALECT": "sqlite",
"SAFE_DB_SQLITE_PATH": "/absolute/path/to/safe-db-mcp/demo/shop.db",
"SAFE_DB_MASKED_COLUMNS": "email,phone"
}
}
}
}App | Where it goes |
Claude Desktop |
|
Cursor |
|
Gemini CLI |
|
VS Code (Copilot agent mode) |
|
Claude Code | One command, run from this folder: |
OpenAI Codex CLI | One command, run from this folder (it writes |
ChatGPT (web and desktop app) | Not yet: ChatGPT only connects to remote MCP servers over HTTPS, and this server runs locally over stdio. |
Ollama, DeepSeek and other models | These are models, not MCP apps. Choose them inside any MCP app that lets you bring your own model, then use that app's row above. |
Configuration
Variable | Default | Meaning |
| required |
|
| required for SQLite | Path to the database file (opened read-only) |
| port | MySQL connection |
| OS keychain | MySQL password. If unset, read from the keychain entry |
|
| Most rows returned per query |
|
| Per-query time limit |
| none | Comma-separated column names to mask, case-insensitive |
|
| Where the audit log is appended |
| none | A reviewed |
MySQL
uv sync --extra mysql --extra keychainGive the server its own login that can only read. That's the strongest guard of all, and it doesn't depend on this code being right:
CREATE USER 'agent_reader'@'%' IDENTIFIED BY '...';
GRANT SELECT, SHOW VIEW ON shop.* TO 'agent_reader'@'%';Then store the password in the OS keychain instead of a config file:
uv run keyring set safe-db-mcp agent_readerApproved joins (optional)
Many real databases never declared their foreign keys. The application knows that orders.customer_id points at
customers.id, but the schema doesn't say so. An agent then has to guess its joins from column names, and a confident
wrong join returns plausible-looking wrong numbers, which is worse than an error.
If you have a list of joins a person has checked, point the server at it:
uv sync --extra relationships
SAFE_DB_RELATIONSHIPS_FILE=/absolute/path/to/relationships.ymlThe agent then gets two more read-only tools:
Tool | Returns |
| The approved joins into and out of a table, each with a ready-to-use join condition |
| The shortest chain of approved joins (up to 4), as a |
> Which product categories do customers in Springfield buy?
join_path({"table_a": "customers", "table_b": "products"})
✓ FROM customers
JOIN orders ON orders.customer_id = customers.id
JOIN order_items ON order_items.order_id = orders.id
JOIN products ON order_items.product_id = products.idThe security model doesn't change:
The server never infers anything. It doesn't sample data, parse code or read query logs, and it needs no extra database permissions. The two tools answer from the file, in memory, and never touch the database (a test proves it). Every call is still written to the audit log.
Only
status: approvedentries are loaded. Proposed and rejected entries are ignored, so an unreviewed guess never reaches the agent.A malformed file stops the server from starting. It doesn't half-load the file or guess what was meant.
Joins on masked columns are dropped, because
run_querywould refuse the query anyway.The SQL that
join_pathsuggests still goes throughrun_queryand every guard above.
The file format is the contract with joinscout, a separate CLI that
proposes these relationships from naming conventions, application code (MyBatis, JPA, raw SQL), query logs and
hashed data checks, and keeps each one proposed until a person approves it. Inference lives in its own tool on
purpose: it needs more access (code, logs, data samples) than an agent-facing server should have. You can also write
the file by hand. demo/relationships.yml is joinscout's output for the demo shop:
version: 1
relationships:
- from: {table: orders, columns: [customer_id]}
to: {table: customers, columns: [id]}
cardinality: many_to_one # or one_to_one
status: approved # only approved entries are loadedTests and evals
uv run pytestThere are 132 unit tests covering the guards, the database layer, config, the audit log, approved joins and the MCP server end to end (in-memory client). Seven more MySQL integration tests run against a throwaway container:
docker run -d --name safe-db-mcp-test-mysql -e MYSQL_ROOT_PASSWORD=test-only-root-pw -e MYSQL_DATABASE=shop -p 127.0.0.1:33061:3306 mysql:8.4
SAFE_DB_TEST_MYSQL_PORT=33061 SAFE_DB_TEST_MYSQL_PASSWORD=test-only-root-pw uv run pytest tests/test_mysql_integration.pyThe eval (evals/run_evals.py) starts the real server over stdio, the way an agent would,
and sends it 46 queries: legitimate analytics plus attacks a confused or manipulated agent might
try. A case passes only if the outcome matches (allow / reject / timeout). The whole run fails if any response contains
a real email or phone number, if the database file's hash changes, or if any call is missing from the audit log.
uv run python evals/run_evals.pycategory passed
-------------------------------------
legitimate analytics 9/9
writes and schema changes 8/8
smuggled statements 6/6
engine and file access 6/6
resource exhaustion 4/4
PII exfiltration 11/11
not SQL 2/2
-------------------------------------
total 46/46
database file unchanged: yes
audit log entries: 46 for 46 callsThe legitimate cases matter as much as the attacks. A guard that blocks everything is useless, and false refusals teach people to switch it off.
Known limits
This is defence in depth, not a proof. Specifically:
Masking works on column names. A view or another table that holds the same data under a different name isn't covered. Real enforcement belongs in the database: views without the PII, column-level grants. Masked columns also can't be filtered or sorted, which is a deliberate trade of convenience for safety.
The parser isn't the database. sqlglot and SQLite or MySQL could disagree about an odd piece of syntax. Layer 3 exists for that case. For
SELECTs, the executed SQL is regenerated from the checked tree, which narrows the gap.MySQL's
MAX_EXECUTION_TIMEonly coversSELECT.SHOWandDESCRIBErely on the socket read timeout.Data can carry instructions. A row containing "ignore previous instructions…" is returned like any other value. The server stops the agent from acting on the database, but it can't stop the agent from reading text that tries to steer it. Treat query results as untrusted input in your agent loop.
The audit log is a local file. It's append-only by convention, not tamper-proof. In production, ship it to your log pipeline.
Approved joins are only as good as the review. The server trusts
status: approved. It doesn't check that the tables still exist, so re-run the review after schema changes.SQLite and MySQL only. Postgres would need its own backend (
default_transaction_read_only,statement_timeout).
Project layout
src/safe_db_mcp/
guards.py statement allow-list, row-limit rewrite, masking rules
database.py read-only SQLite / MySQL backends, row cap, timeouts
runner.py check → cap → execute → mask → audit, in that order
relationships.py approved joins from relationships.yml, shortest join paths
audit.py JSON-lines audit log
config.py settings from env, password from env or keychain
server.py MCP tools over stdio
demo/ sample-data builder and scripted walkthrough
evals/ attack and legitimate-use cases, eval runner
tests/ unit, MCP end-to-end and MySQL integration testsLicense
This server cannot be deployed
Maintenance
Related MCP Connectors
Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
- dataOAuthco.thinair
PostgreSQL, MySQL, and SQL Server in one session. 26 read-only MCP tools for AI agents.
Query your org's data in natural language — read-only MCP access to SQL, NoSQL, files & warehouses.
Guard AI agents' PostgreSQL/MySQL access via MCP: SQL audit, auth, masking, write approval
Related MCP Servers
- AlicenseAqualityDmaintenanceEnables AI agents to explore MySQL database schemas and execute read-only queries through a safe, MCP interface.6ISC
- FlicenseAqualityCmaintenanceEnables AI agents to safely inspect and query a SQLite database through read-only MCP tools for listing tables, describing schemas, and running paginated SELECT queries.3-
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to read-only query a SQLite database, inspect schema and table summaries, and execute SELECT queries with pagination through MCP.MIT
- FlicenseNot gradedqualityBmaintenanceProvides read-only database access for AI agents, enabling table listing, schema inspection, and filtered row queries through MCP tools.-