Skip to main content
Glama
azam-ahmad-khan

safe-db-mcp

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 (guards.py)

Parses the SQL into a syntax tree with sqlglot and allows exactly one SELECT, SHOW, DESCRIBE or EXPLAIN. It walks the whole tree to reject writes hidden inside (WITH … DELETE, SELECT … INTO), row locks (FOR UPDATE), dangerous functions (SLEEP, BENCHMARK, LOAD_FILE, load_extension, randomblob), leaky SHOWs (PROCESSLIST, GRANTS) and EXPLAIN ANALYZE, which actually runs the query.

A keyword blacklist is a bouncer reading name tags, and DELETE/**/FROM or SELECT 1; DROP … walk straight past it. A parser reads the sentence the way the database will. Anything it can't parse is rejected: unknown means no.

2

Row cap (guards.py · database.py)

Rewrites SELECTs to LIMIT max_rows + 1, so the database stops early, then fetches at most that many rows and tells the agent when the result was cut short.

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 (database.py)

SQLite opens with mode=ro and PRAGMA query_only. MySQL runs SET SESSION TRANSACTION READ ONLY and MAX_EXECUTION_TIME, with a socket read timeout as a backstop. SQLite has no statement timeout, so a progress handler aborts the query at the deadline.

Belt and braces: even if a write slipped past layer 1, the database refuses it. The tests prove this by sending DELETEs straight to the connection, as MySQL root.

4

PII masking (guards.py · runner.py)

Values in configured columns (e.g. email,phone) come back as [masked]. Those columns may be selected only as-is, at the top level of the query.

Masking by column name alone is easy to dodge: email AS x, lower(email), UNION, subqueries. Filtering or sorting on a masked column (WHERE email LIKE 'a%') lets an agent guess the value one question at a time. Those patterns are rejected.

5

Audit log (audit.py)

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 (0600).

When someone asks "what did the agent look at last Tuesday?", there's an answer.

6

Credentials out of reach (config.py)

Connection settings come from environment variables. The password comes from SAFE_DB_PASSWORD or the OS keychain, and it never appears in repr() or logs.

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 sync

Build 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.py

Watch a scripted agent session, with good questions, bad ones and the resulting audit log:

uv run python demo/walkthrough.py

Connect 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

uv

Arguments

--directory /absolute/path/to/safe-db-mcp run safe-db-mcp

Environment

SAFE_DB_DIALECT=sqliteSAFE_DB_SQLITE_PATH=/absolute/path/to/safe-db-mcp/demo/shop.dbSAFE_DB_MASKED_COLUMNS=email,phone

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

claude_desktop_config.json: the block above

Cursor

.cursor/mcp.json in a project, or ~/.cursor/mcp.json: the block above, plus "type": "stdio"

Gemini CLI

.gemini/settings.json in a project, or ~/.gemini/settings.json: the block above

VS Code (Copilot agent mode)

.vscode/mcp.json: the same entry, but the top-level key is servers and it needs "type": "stdio"

Claude Code

One command, run from this folder:claude mcp add safe-db -e SAFE_DB_DIALECT=sqlite -e SAFE_DB_SQLITE_PATH="$PWD/demo/shop.db" -e SAFE_DB_MASKED_COLUMNS=email,phone -- uv --directory "$PWD" run safe-db-mcp

OpenAI Codex CLI

One command, run from this folder (it writes ~/.codex/config.toml):codex mcp add safe-db --env SAFE_DB_DIALECT=sqlite --env SAFE_DB_SQLITE_PATH="$PWD/demo/shop.db" --env SAFE_DB_MASKED_COLUMNS=email,phone -- uv --directory "$PWD" run safe-db-mcp

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

SAFE_DB_DIALECT

required

sqlite or mysql

SAFE_DB_SQLITE_PATH

required for SQLite

Path to the database file (opened read-only)

SAFE_DB_HOST, SAFE_DB_PORT, SAFE_DB_USER, SAFE_DB_NAME

port 3306

MySQL connection

SAFE_DB_PASSWORD

OS keychain

MySQL password. If unset, read from the keychain entry safe-db-mcp / <user>

SAFE_DB_MAX_ROWS

200

Most rows returned per query

SAFE_DB_TIMEOUT_SECONDS

10

Per-query time limit

SAFE_DB_MASKED_COLUMNS

none

Comma-separated column names to mask, case-insensitive

SAFE_DB_AUDIT_LOG

~/.safe-db-mcp/audit.jsonl

Where the audit log is appended

SAFE_DB_RELATIONSHIPS_FILE

none

A reviewed relationships.yml; turns on join_path and describe_relationships

MySQL

uv sync --extra mysql --extra keychain

Give 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_reader

Approved 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.yml

The agent then gets two more read-only tools:

Tool

Returns

describe_relationships(table_name)

The approved joins into and out of a table, each with a ready-to-use join condition

join_path(table_a, table_b)

The shortest chain of approved joins (up to 4), as a FROM … JOIN … ON … clause

> 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.id

The 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: approved entries 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_query would refuse the query anyway.

  • The SQL that join_path suggests still goes through run_query and 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 loaded

Tests and evals

uv run pytest

There 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.py

The 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.py
category                       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 calls

The 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_TIME only covers SELECT. SHOW and DESCRIBE rely 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 tests

License

MIT

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI agents to read-only query a SQLite database, inspect schema and table summaries, and execute SELECT queries with pagination through MCP.
    MIT
  • F
    license
    Not graded
    quality
    B
    maintenance
    Provides read-only database access for AI agents, enabling table listing, schema inspection, and filtered row queries through MCP tools.
    -