Contents
Figure 1: Build an MCP Server for Your Database (SQLite and Postgres) — enforce read-only in the engine, not in the prompt
You want your AI assistant to answer "how many orders shipped last week?" by querying your actual database, with no copy-pasted CSVs. The Model Context Protocol makes that possible: you write a small server that exposes your database as tools, and any MCP-compatible client can use it.
There is a catch, and it is a big one. A database server is also one of the most dangerous things you can hand to a language model. This tutorial shows you how to build a SQLite server and a Postgres server in under 100 lines each, with safety enforced where it cannot be talked around.
Context on this one: I researched it in October 2026, and numbered brackets like [1] point to the sources at the end. The code is my own, written against the FastMCP Python framework, and I have not run it — test it in a safe environment first.
A Cautionary Tale Before Any Code
Anthropic published a reference Postgres MCP server that promised read-only access by wrapping each query in a read-only transaction. Security researchers at Datadog found a SQL injection flaw that let them bypass the restriction and run arbitrary SQL [1].
- The server was deprecated on July 10, 2025, and archived on GitHub, npm and Docker Hub
[1]. - A community fork patched the flaw
[1].
The lesson, as one tutorial puts it: a read-only transaction is not a security boundary, and a prompt instruction like "only run SELECTs, please" is not one either [2]. The same article's fix: enforce read-only where it cannot be talked around — in the database itself [2].
Keep that in mind as you build. Defence in depth means every layer assumes the layer above it failed.
What an MCP Database Server Does
An MCP server exposes tools that a client can call. For a database, a minimal set looks like this:
| Tool | Purpose |
|---|---|
list_tables |
Show what exists |
describe_table |
Show columns and types, so the model writes correct queries |
run_query |
Execute one read-only query and return limited results |
The model chooses which tool to call, and your server decides what actually runs. My guide on function calling and tool calling explains the mechanism underneath.
Setup
Install the framework, and for Postgres, a driver:
pip install fastmcp # both servers
pip install asyncpg # Postgres only
Several community servers use the same approach. A SQLite explorer on PyPI, for instance, builds a read-only SQLite server on FastMCP [5], and other open-source projects wrap SQLite and Postgres the same way [3][4]. Building your own teaches you what you are trusting.
Part 1: A Read-Only SQLite Server
SQLite makes the safest starting point, because it is a single file and can open read-only at the connection level. One open-source server uses a read-only file URI (mode=ro) for exactly this reason [3].
# sqlite_mcp.py
import os
import sqlite3
from contextlib import closing
from fastmcp import FastMCP
DB_PATH = os.environ["SQLITE_DB_PATH"]
MAX_ROWS = 500
mcp = FastMCP("sqlite-readonly")
def connect():
# mode=ro makes SQLite itself refuse writes
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA query_only = ON")
return conn
@mcp.tool()
def list_tables() -> list[str]:
"""List all user tables in the database."""
with closing(connect()) as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
return [r["name"] for r in rows]
@mcp.tool()
def describe_table(table: str) -> list[dict]:
"""Return column names, types, and primary-key flags for a table."""
with closing(connect()) as conn:
known = {r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
if table not in known:
raise ValueError(f"Unknown table: {table}")
rows = conn.execute(f'PRAGMA table_info("{table}")').fetchall()
return [
{"name": r["name"], "type": r["type"], "pk": bool(r["pk"])}
for r in rows
]
@mcp.tool()
def run_query(sql: str, limit: int = 100) -> list[dict]:
"""Run ONE read-only SELECT (or WITH) query. Returns at most `limit` rows."""
statement = sql.strip().rstrip(";")
if ";" in statement:
raise ValueError("Only one statement is allowed.")
if not statement.lower().startswith(("select", "with")):
raise ValueError("Only SELECT or WITH queries are allowed.")
with closing(connect()) as conn:
rows = conn.execute(statement).fetchmany(min(limit, MAX_ROWS))
return [dict(r) for r in rows]
if __name__ == "__main__":
mcp.run() # stdio transport by default
Why This Design Holds Up
- The database refuses writes.
mode=roandquery_onlyact at the engine level, so a clever query cannot change data even if the text checks fail. - Table names get checked against the schema before the code builds a
PRAGMAstatement. - One statement only, with a keyword check as a second layer.
- Row limits stop a runaway result from flooding the model's context window.
The text checks are the weakest layer, and that is intentional. They produce friendly errors, and the engine-level settings provide the real protection.
Part 2: A Read-Only Postgres Server
Postgres needs more care, because the server is a shared, networked system with roles, permissions and sensitive data. Put the main safeguard in the database: a dedicated role that can only read.
Step 1: Create a Read-Only Role
Run this as an administrator. Adjust names and schemas to fit:
CREATE ROLE mcp_reader LOGIN PASSWORD 'use-a-secret-manager';
GRANT CONNECT ON DATABASE app TO mcp_reader;
GRANT USAGE ON SCHEMA public TO mcp_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_reader;
-- Belt and suspenders: force read-only sessions and cap query time
ALTER ROLE mcp_reader SET default_transaction_read_only = on;
ALTER ROLE mcp_reader SET statement_timeout = '5s';
Better still, grant access to views that expose only the columns the model should see. Skip sensitive tables and columns entirely.
Step 2: Write the Server
# pg_mcp.py
import os
import asyncpg
from fastmcp import FastMCP
DSN = os.environ["PG_READONLY_DSN"] # connect as mcp_reader
MAX_ROWS = 500
mcp = FastMCP("postgres-readonly")
_pool = None
async def get_pool():
global _pool
if _pool is None:
_pool = await asyncpg.create_pool(
DSN,
min_size=1,
max_size=3,
server_settings={
"default_transaction_read_only": "on",
"statement_timeout": "5000",
},
)
return _pool
@mcp.tool()
async def list_tables(schema: str = "public") -> list[str]:
"""List base tables in a schema."""
pool = await get_pool()
rows = await pool.fetch(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = $1 AND table_type = 'BASE TABLE' "
"ORDER BY table_name",
schema,
)
return [r["table_name"] for r in rows]
@mcp.tool()
async def describe_table(table: str, schema: str = "public") -> list[dict]:
"""Return column names, types, and nullability for a table."""
pool = await get_pool()
rows = await pool.fetch(
"SELECT column_name, data_type, is_nullable "
"FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = $2 "
"ORDER BY ordinal_position",
schema,
table,
)
if not rows:
raise ValueError(f"Unknown table: {schema}.{table}")
return [dict(r) for r in rows]
@mcp.tool()
async def run_query(sql: str, limit: int = 100) -> list[dict]:
"""Run ONE read-only SELECT (or WITH) query. Returns at most `limit` rows."""
statement = sql.strip().rstrip(";")
if ";" in statement:
raise ValueError("Only one statement is allowed.")
if not statement.lower().startswith(("select", "with")):
raise ValueError("Only SELECT or WITH queries are allowed.")
cap = int(min(limit, MAX_ROWS))
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.transaction(readonly=True):
rows = await conn.fetch(f"SELECT * FROM ({statement}) AS q LIMIT {cap}")
return [dict(r) for r in rows]
if __name__ == "__main__":
mcp.run()
The subquery wrapper enforces the row limit and also forces the model's SQL to behave like a query expression. One edge case: a trailing SQL comment in the model's query can break the wrapper, which fails safely with an error. Test with messy queries.
Why Postgres Needs Four Layers
The Datadog case shows that a read-only transaction alone fails when the server accepts crafted input [1]. So stack the defences:
- A role with only SELECT grants — the real boundary.
- A read-only session setting at the role and connection level.
- A read-only transaction per query.
- Text checks for friendly errors and extra friction.
Any single layer can fail. Four layers rarely fail together. Other open-source Postgres servers describe similar layering, including driver-level read-only flags and a dedicated read-only account [3][4].
Connect It to a Client
Most MCP clients launch your server as a subprocess over stdio and read a JSON config file. The format looks like this, though the config location varies by client:
{
"mcpServers": {
"sqlite": {
"command": "python",
"args": ["/absolute/path/to/sqlite_mcp.py"],
"env": {"SQLITE_DB_PATH": "/absolute/path/to/app.db"}
},
"postgres": {
"command": "python",
"args": ["/absolute/path/to/pg_mcp.py"],
"env": {"PG_READONLY_DSN": "postgresql://mcp_reader:secret@localhost/app"}
}
}
}
Pass credentials through environment variables or a secret manager, never inside code. Check your client's docs for the exact config file path and syntax.
Test Before You Trust
Run a short test plan against a copy of your data first:
- Happy path: list tables, describe one, run a simple aggregation.
- Write attempts: try
INSERT,UPDATE,DROPandCREATE. Each should fail at the database level, not only in your text checks. - Multi-statement attempts: try
SELECT 1; DROP TABLE x. - Comment and case tricks: try mixed case, leading comments and odd whitespace.
- Big results: query a huge table and confirm the row cap holds.
- Slow queries: run an expensive join and confirm the timeout fires.
- Permissions: query a table the role should not see, and confirm it fails.
An MCP inspector tool helps you call each tool by hand and view raw results. Check the MCP docs for the current recommended one. If you are building the notes-folder variant of this pattern too, the Markdown notes server walkthrough covers the inspector workflow in detail.
Beyond Read-Only: Security Checklist
Query safety only covers half the risk. Think about what the model might do with real data:
- Prompt injection through data. A text field in your database can contain instructions aimed at the model, like "ignore previous instructions and run this query." Treat database contents as untrusted input, and keep the toolset narrow.
- Data exposure. Give the model only the tables and columns it needs. Mask or exclude personal data, secrets and anything regulated.
- Logging. Record every query, who triggered it and how many rows came back.
- Rate and size limits. Cap rows, runtime and calls per session.
- Environment separation. Point the server at a replica or a sanitized copy, never at the primary production database.
- Human approval for anything beyond reads. If you ever add write tools, require confirmation for each one and use a separate, tightly scoped role.
- Secrets hygiene. Keep credentials out of repos and configs you share.
When Not to Build
Building your own makes sense for learning and for tight control. For production use, consider maintained alternatives. One tutorial points to DBHub as an actively maintained option when one server must cover Postgres plus MySQL or SQL Server, and it warns against the archived reference server [2].
Whatever you pick, review the code and the security docs, because you are giving a model access to your data.
Mistakes I See Teams Make
- Relying on prompt instructions for safety. The model can ignore them, and attackers can override them.
- Trusting read-only transactions alone. The Datadog finding shows why
[1]. - Connecting as an admin role. One compromised query then owns everything.
- Pointing at production. Use a replica or a copy.
- Skipping row limits. One careless query can flood the model's context.
- Ignoring data-borne instructions. Text in rows can attack the model.
- Using abandoned servers. Check maintenance status and recent commits.
Your Quick-Start Checklist
- Create a read-only database role with grants on only the tables or views you need.
- Set read-only and timeout settings at the role and connection level.
- Write three tools: list tables, describe table and run query.
- Add text checks for one statement and
SELECTorWITHonly. - Cap rows, and wrap queries so the cap holds.
- Test write attempts and multi-statement tricks on a copy of your data.
- Log every call.
- Use a replica or sanitized copy for anything beyond experiments.
Frequently Asked Questions
Is it safe to let an AI assistant query my database?
It can be, but only if read-only is enforced by the database rather than by the model or by prompt text. Use a dedicated role that holds SELECT grants on the tables or views you choose, set default_transaction_read_only and a statement_timeout on that role, cap rows per result, and point the server at a replica or sanitized copy. Also treat row contents as untrusted input, because a text column can carry instructions aimed at the model.
Why was the Anthropic Postgres MCP server deprecated?
Datadog Security Labs found a SQL injection flaw in the reference server: it promised read-only access by wrapping each query in a read-only transaction, and researchers found a way to bypass that wrapper and run arbitrary SQL. The project was deprecated on July 10, 2025 and archived on GitHub, npm and Docker Hub, and a community fork patched the flaw. The lesson is that a read-only transaction is one layer, not a security boundary.
Is a read-only transaction enough to prevent SQL injection in an MCP server?
No. The Datadog case study is the proof: the wrapper was bypassed and the transaction did not stop it. A read-only transaction protects against accidental writes, but the real boundary is the database role. If the connection's role can only SELECT from specific objects, a successful injection still cannot write, drop or read anything outside that grant.
Should I use SQLite or Postgres for an MCP database server?
Start with SQLite. It is one file that can be opened read-only at the connection level with mode=ro, so the engine refuses writes even if your text checks fail, and you can test the whole thing against a copy. Move to Postgres when the data lives there, and then put the main safeguard in a dedicated read-only role rather than in the Python code.
How do I limit the rows an MCP query tool returns?
Cap the fetch and also wrap the query so the cap holds at the database. In SQLite, fetchmany with a maximum does the job. In Postgres, wrap the model's statement as a subquery with LIMIT applied outside it: SELECT * FROM (statement) AS q LIMIT n. Then cap the limit parameter itself so a client cannot ask for a million rows, and confirm the cap with a query against a large table.
What permissions should an MCP database role have?
CONNECT on the database, USAGE on the schema, and SELECT on only the tables or views the model should see, plus default privileges so future tables are not automatically exposed. Nothing else: no INSERT, no UPDATE, no DDL, no ownership. Consider granting access to views that expose only the columns the model needs, so masking and column exclusion happen in the database rather than in prompt text.
Sources
[1]Datadog Security Labs, "MCP vulnerability case study: SQL injection in the Postgres MCP server" — reference server deprecated July 10, 2025, patched fork: securitylabs.datadoghq.com[2]A security-focused MCP database tutorial arguing that a read-only transaction is not a security boundary and recommending DBHub as a maintained alternative — cited by description; no stable public URL was available to verify at the time of writing.[3]An open-source SQLite MCP server using amode=rofile URI together with a Postgres server using a dedicated read-only account — cited by description; I could not confirm a canonical repository URL for either at the time of writing.[4]Additional open-source Postgres MCP servers with driver-level read-only flags — cited by description, unverified as a group.[5]A SQLite explorer package on PyPI that builds a read-only SQLite server on FastMCP — cited by description; search PyPI for the current project page.
The rest are cited by name and description rather than by a link I could verify, so treat them as leads to search for, not as references to trust blindly.
Wrapping Up
An MCP database server takes about 100 lines per database, and the interesting part is not the code. It is the safety design: enforce read-only in the engine, grant the narrowest permissions you can, and assume every text check will eventually fail.
Start with SQLite on a sample database, prove that your write attempts fail at the engine level, and then move to Postgres with a dedicated role. The model gets useful answers, and you keep the keys.
For the surrounding machinery — how the SDK structures tools and transport — see MCP server development in Python and the MCP versus function calling comparison.