Give a language model a database tool and the obvious failure mode is not a
clever exploit. It is a well-meaning model running DELETE FROM orders because a
prompt told it to “clean up test data,” or issuing SELECT * FROM events against
a billion-row table and stalling everything behind it. A database MCP server has
to assume the caller is careless, and sometimes hostile.
In this tutorial you will build a FastMCP server that exposes a Postgres database to an MCP client as strictly read-only, with the safety built in layers so no single mistake opens a hole:
- a dedicated read-only database role that has
SELECTand nothing else, so a write cannot succeed even if every line of Python above it is wrong; - a read-only transaction and a per-statement timeout applied at connect time, so runaway and write queries are refused by Postgres itself;
- a table and column allow-list so the structured query tool can only touch what you named, and identifiers are never interpolated by hand;
- parameterized values everywhere, so filter values can never break out into SQL;
- a row cap so a broad query cannot flood the model’s context.
Postgres runs in Docker so there is nothing to install on the host, the server
is a small uv project, and pytest proves each guard actually holds.
What you will build
- A
.env-based credential setup (with a committed.env.exampletemplate) so no password lives in the source, the compose file, or the SQL. - A
docker-compose.ymlrunningpostgres:17with a seeded schema and aSELECT-only role, created before any table is granted out. - A
config.pythat reads connection, allow-list, timeout, and row-cap settings from the environment. - A
db.pythat opens read-only, timeout-bounded connections. - A
safety.pywith the query guard and identifier validation, unit-tested with no database required. - A
server.pyexposing four tools:list_tables,describe_table,query_table(allow-list driven), andrun_query(guarded raw SELECT). - A
pytestsuite that proves writes are rejected, timeouts fire, the allow-list holds, and row caps apply.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- Docker Desktop 4.30+ (docker.com) —
docker --versionand a running daemon (docker ps). - uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Basic SQL familiarity. You do not need
psqlon the host — the Makefile reaches Postgres throughdocker compose exec.
Everything runs through Docker, uv, and make.
Step 1: Scaffold and lock down hygiene
Create the workspace and a .gitignore first, before any other file, so
environment files and runtime artifacts can never be committed.
Create the files
mkdir -p readonly-postgres-mcp-server-macos
cd readonly-postgres-mcp-server-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
# Environment / secrets
.env
*.env
# Runtime / deploy artifacts
logs/
*.log
# Postgres data (if a bind mount is ever added)
pgdata/
# OS / editor noise
.DS_Store
Detailed breakdown
.envis ignored because it holds the real credentials (the connection string carries the role’s password). The server reads configuration from the environment, and the file that fills it in stays out of git. A committed.env.example(added in the next step) is the template; the ignored.envis the copy with real values.pgdata/covers the case where you later switch the database from a Docker named volume to a bind mount. As written, Postgres data lives in a named volume managed by Docker, so there is nothing on disk to ignore yet — but ignoring it up front means a future change cannot leak the database into a commit.- The rest is standard
uv/macOS hygiene.
Step 2: Keep credentials out of source with .env
Passwords do not belong in code, in the compose file, or in the SQL. They belong
in the environment. Define them in a .env file that git ignores, and commit a
.env.example template so anyone can see which variables to set without ever
seeing a real secret. The compose file and the server both read from there.
Create the file
touch .env.example
Add the code: .env.example
# Development-only credentials for the local Docker Postgres (bound to localhost).
# Copy this file to .env (`make setup` does it) and edit for anything beyond local
# dev. .env is gitignored and must never be committed. In production these values
# come from your secrets manager or injected environment, not a file in the repo.
# Superuser password for the Postgres container.
POSTGRES_PASSWORD=postgres
# Password for the SELECT-only role the MCP server logs in as. init.sh creates the
# role with this value; DATABASE_URL below reuses it.
MCP_READONLY_PASSWORD=readonly_pw
# What the MCP server connects with. Reuses MCP_READONLY_PASSWORD above.
DATABASE_URL=postgresql://mcp_readonly:${MCP_READONLY_PASSWORD}@localhost:5544/appdb
Detailed breakdown
.env.exampleis committed;.envis not. The example documents every variable with a working development value so the tutorial runs out of the box, while the real file (a copy you make) stays out of git. This is the pattern to copy: a checked-in template, a git-ignored secret file.- These are throwaway local-dev values, not secrets. The database is a
container bound to
localhost. In production you would injectDATABASE_URLfrom a secrets manager and never write it to a file — which is exactly why the code reads it from the environment rather than hard-coding it. DATABASE_URLreuses${MCP_READONLY_PASSWORD}so the password is written once.python-dotenv(a dependency of the project) expands the reference when the configuration module loads the file. If you choose a password with URL-reserved characters (@,:,/), percent-encode it inDATABASE_URL.
Step 3: Run Postgres with a read-only role
The database is the real security boundary, so set it up next. The container
seeds a small schema, inserts deterministic data, then creates a login role that
is granted SELECT and nothing else. The MCP server connects as that role. Even
a bug that builds an INSERT cannot write, because the role has no privilege to.
The role’s password comes from the environment, not the SQL.
Create the files
mkdir -p db
touch docker-compose.yml db/init.sh
Add the code: docker-compose.yml
services:
db:
image: postgres:17
container_name: readonly-pg
environment:
POSTGRES_USER: postgres
# Credentials come from .env (copy .env.example). Compose fails with this
# message if the variable is unset, so a password is never defaulted here.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env (copy .env.example)}
POSTGRES_DB: appdb
# Passed through so init.sh can create the read-only role with it.
MCP_READONLY_PASSWORD: ${MCP_READONLY_PASSWORD:?set MCP_READONLY_PASSWORD in .env}
ports:
# Host 5544 avoids colliding with a local Postgres on 5432.
- "5544:5432"
volumes:
# Runs once, on an empty data directory, as the postgres superuser.
- ./db/init.sh:/docker-entrypoint-initdb.d/init.sh:ro
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 2s
timeout: 3s
retries: 15
volumes:
pgdata:
Detailed breakdown
- Compose reads
.envautomatically from the project directory and substitutes${POSTGRES_PASSWORD}and${MCP_READONLY_PASSWORD}. The:?...form makesdocker compose upfail with a clear message if either is unset, so the stack never starts with a blank or defaulted password. - Host port 5544 maps to the container’s 5432. A Mac with a Homebrew Postgres already listening on 5432 would otherwise refuse the mapping; 5544 sidesteps that. Every connection string in this article uses 5544.
init.shis mounted into/docker-entrypoint-initdb.d/. The official image runs every script there exactly once, the first time the data directory is empty, as the superuser. This is where the schema, seed data, and role are created. A.shscript (rather than a.sqlfile) is used so it can readMCP_READONLY_PASSWORDfrom the environment. If you change it later, you must recreate the volume (make reset) for it to run again — the entrypoint skips initialization when data already exists.- The healthcheck uses
pg_isreadysodocker compose up --waitblocks until Postgres actually accepts connections, not just until the container starts. pgdatais a named volume, so data survivesmake downand is wiped only bymake reset.
Add the code: db/init.sh
#!/bin/bash
# Runs once, on first container start, inside the Postgres container. Creates the
# schema, seed data, and a SELECT-only role. The role's password comes from the
# MCP_READONLY_PASSWORD environment variable (passed in from .env by compose), so
# no credential is hard-coded in the repo.
set -euo pipefail
psql -v ON_ERROR_STOP=1 \
--username "$POSTGRES_USER" \
--dbname "$POSTGRES_DB" \
--set ro_pw="$MCP_READONLY_PASSWORD" \
--set dbname="$POSTGRES_DB" <<'EOSQL'
CREATE TABLE customers (
id integer PRIMARY KEY,
name text NOT NULL,
email text NOT NULL,
city text NOT NULL
);
CREATE TABLE orders (
id integer PRIMARY KEY,
customer_id integer NOT NULL REFERENCES customers (id),
total_cents integer NOT NULL,
status text NOT NULL
);
INSERT INTO customers (id, name, email, city) VALUES
(1, 'Ada Lovelace', '[email protected]', 'London'),
(2, 'Alan Turing', '[email protected]', 'London'),
(3, 'Grace Hopper', '[email protected]', 'New York'),
(4, 'Katherine Johnson', '[email protected]','Hampton');
INSERT INTO orders (id, customer_id, total_cents, status) VALUES
(1, 1, 1299, 'paid'),
(2, 1, 4500, 'paid'),
(3, 2, 999, 'refunded'),
(4, 3, 15000, 'paid'),
(5, 3, 2500, 'pending');
-- The MCP server logs in as this role. It can read the two tables and nothing
-- more: no INSERT/UPDATE/DELETE, no DDL, no other schema. The password is
-- injected from the environment via the :'ro_pw' psql variable, not written here.
CREATE ROLE mcp_readonly LOGIN PASSWORD :'ro_pw';
GRANT CONNECT ON DATABASE :"dbname" TO mcp_readonly;
GRANT USAGE ON SCHEMA public TO mcp_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly;
-- Any table added to this schema later is readable too, but still only SELECT.
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO mcp_readonly;
EOSQL
Make it executable so the entrypoint runs it in its own process:
chmod +x db/init.sh
Detailed breakdown
- The password is injected, never written.
psql --set ro_pw="$MCP_READONLY_PASSWORD"binds a psql variable from the environment, andCREATE ROLE mcp_readonly LOGIN PASSWORD :'ro_pw'substitutes it as a safely-quoted literal. The:"dbname"form does the same for the database name as a quoted identifier. Nothing secret lives in the file. - The role is created with
LOGINso the server can connect as it. It is grantedCONNECT,USAGEon the schema, andSELECTon the tables. It is never grantedINSERT,UPDATE,DELETE,TRUNCATE, or any DDL, so Postgres rejects those outright regardless of what SQL reaches it. ALTER DEFAULT PRIVILEGESmeans tables you add topubliclater are also readable by the role without re-running a grant — but the ceiling stays atSELECT.- The seed data is deterministic (fixed ids and values), so every count and
sum in this article is reproducible. London has two customers; the paid orders
total
1299 + 4500 + 15000 = 20799cents.
Copy the template to a real .env, then start it:
cp .env.example .env # real values live here; .env is gitignored
docker compose up -d --wait
--wait returns only once the healthcheck passes. You now have Postgres on
localhost:5544, seeded, with a SELECT-only role ready.
Step 4: Initialize the uv project
Create the uv project and add FastMCP, the Postgres driver, and python-dotenv
(which loads .env for the server).
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp "psycopg[binary]" python-dotenv
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "A safe read-only Postgres MCP server"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.3",
"psycopg[binary]>=3.2.0",
"python-dotenv>=1.0.0",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
psycopg[binary]is psycopg 3 with the precompiled C driver, so there is no build step and no need for a locallibpq. It is the maintained successor topsycopg2and has first-class support for safe SQL composition, which the allow-list tool relies on.python-dotenvloads the.envfile into the process environment so the server picks upDATABASE_URLin local development. It does not override variables already set in the environment, so it is inert in production.fastmcpis the MCP server framework.pytest-asynciolets the tool tests drive the server through FastMCP’s in-memory async client.
Step 5: Centralize configuration
Read every setting from the environment so the same code runs against the local
container, a staging database, or production by changing variables only. Secrets
come from .env in development and injected variables in production; nothing is
hard-coded.
Create the file
touch config.py
Add the code: config.py
"""Runtime configuration, read once from the environment.
Every security-relevant limit lives here: which role connects, which tables the
structured tool may touch, how long a statement may run, and how many rows a
single call may return. Secrets (the database URL, which carries the role's
password) come from the environment -- loaded from a gitignored .env in local
development -- and are never hard-coded in this file.
"""
import os
from dataclasses import dataclass, field
from dotenv import load_dotenv
# Load .env for local development. Real environment variables (for example, those
# injected by a platform in production) are not overridden, so this is a no-op
# wherever configuration is provided by the environment rather than a file.
load_dotenv()
def _tables_from_env() -> tuple[str, ...]:
raw = os.environ.get("ALLOWED_TABLES", "customers,orders")
return tuple(t.strip() for t in raw.split(",") if t.strip())
@dataclass(frozen=True)
class Settings:
# The connection string carries the read-only role's password, so it comes
# from the environment (.env in dev), never a literal in source.
database_url: str = os.environ.get("DATABASE_URL", "")
allowed_tables: tuple[str, ...] = field(default_factory=_tables_from_env)
statement_timeout_ms: int = int(os.environ.get("STATEMENT_TIMEOUT_MS", "3000"))
max_rows: int = int(os.environ.get("MAX_ROWS", "100"))
settings = Settings()
if not settings.database_url:
raise RuntimeError(
"DATABASE_URL is not set. Copy .env.example to .env (or run `make setup`), "
"or set DATABASE_URL in your environment."
)
Detailed breakdown
load_dotenv()reads.envinto the process environment on import, so a locally-run server or test picks upDATABASE_URL. It does not override variables already present, so in production the platform’s injected values win and the (absent).envis ignored.database_urlhas no default value in code. It comes entirely from the environment; the connection string is the one place a careless deployment could hand the server the superuser, so there is no fallback that could quietly do that. If it is unset, the module raises with a pointer to.env.examplerather than starting misconfigured.allowed_tablesis a tuple parsed from a comma-separated variable. The structuredquery_tabletool refuses anything outside it. Defaulting to the two seeded tables keeps the tutorial runnable; in production you set it explicitly.statement_timeout_msandmax_rowsare the runaway-query guards. They are integers read from the environment so an operator can tighten them without touching code.frozen=Truemakes the settings immutable after load, so nothing mutates a limit at runtime.
Step 6: Open read-only, bounded connections
All database access goes through one helper. It connects as the read-only role
and, at connect time, sets two Postgres parameters for the whole session:
default_transaction_read_only and statement_timeout. Those apply to every
statement the connection runs, so the guarantees do not depend on remembering to
set them per query.
Create the file
touch db.py
Add the code: db.py
"""Read-only, timeout-bounded Postgres connections.
Two libpq startup options are set on every connection:
* default_transaction_read_only=on -> Postgres refuses any write, so even a
query that slips past the Python guard cannot mutate data.
* statement_timeout=<ms> -> the server cancels any statement that runs too
long, so one broad query cannot stall the database.
Combined with the SELECT-only role, that is three independent layers before a
write or a runaway query can do damage.
"""
import psycopg
from psycopg.rows import dict_row
from config import settings
def connect() -> psycopg.Connection:
"""Open a connection as the read-only role with session guards applied."""
return psycopg.connect(
settings.database_url,
autocommit=True,
row_factory=dict_row,
options=(
f"-c statement_timeout={settings.statement_timeout_ms} "
"-c default_transaction_read_only=on"
),
)
Detailed breakdown
- The
optionsstring sets GUC parameters at startup.-c default_transaction_read_only=onmakes every transaction on this connection read-only; Postgres raisesReadOnlySqlTransactionon any write.-c statement_timeout=<ms>cancels any statement exceeding the limit withQueryCanceled. These are enforced by the server, not by Python, so they hold even against SQL the guard in the next step does not anticipate. autocommit=Truemeans eachexecuteruns in its own implicit transaction. Since every transaction is read-only, there is no write to commit and no long-lived transaction to leak.row_factory=dict_rowreturns each row as a plaindict, which serializes straight to JSON for the MCP client.- The role, not the code, is the primary boundary. The read-only transaction
and timeout are defense in depth. If you point
DATABASE_URLat a superuser “to make it work,” you throw the strongest layer away — so don’t.
Step 7: The query guard and identifier validation
Two helpers, both pure functions with no database access, so they are fast to
unit-test. assert_select_only gives a raw SQL string a quick, clear rejection
if it is obviously not a single read query. validate_identifiers checks that
every table and column name the structured tool was handed is one you allow, so a
name is never trusted into SQL.
Create the file
touch safety.py
Add the code: safety.py
"""Input guards for the query tools.
These are the fast, friendly layer: they reject bad input with a clear message
before it reaches Postgres. They are NOT the security boundary -- the read-only
role and read-only transaction are. A guard that only inspects keywords can
always be fooled (a WITH statement can hide a data-modifying CTE), which is
exactly why the database is configured to refuse writes on its own.
"""
class QueryError(ValueError):
"""Raised when a query or identifier fails a safety check."""
def assert_select_only(sql_text: str) -> str:
"""Return a normalized single-statement SELECT, or raise QueryError.
Rejects empty input, multiple statements, and anything that does not begin
with SELECT or WITH. This is a usability guard, not the write barrier.
"""
stripped = sql_text.strip().rstrip(";").strip()
if not stripped:
raise QueryError("query is empty")
if ";" in stripped:
raise QueryError("only a single statement is allowed")
first_word = stripped.split(None, 1)[0].lower()
if first_word not in ("select", "with"):
raise QueryError("only SELECT (or WITH ... SELECT) queries are allowed")
return stripped
def validate_identifiers(names: list[str], allowed: set[str], kind: str) -> None:
"""Raise QueryError if any name is not in the allowed set."""
for name in names:
if name not in allowed:
raise QueryError(f"unknown {kind}: {name!r}")
Detailed breakdown
assert_select_onlytrims one trailing semicolon, then rejects any remaining one. That blocks the classic...; DROP TABLE ...multi-statement injection before it reaches the driver. It also requires the first keyword to beSELECTorWITH.- It is deliberately honest about its limits.
WITH x AS (...) DELETE ...is a valid statement that starts withWITH, so a keyword check alone cannot guarantee read-only. That write is still refused, by the read-only transaction and theSELECT-only role. The guard exists to return a clean error fast, not to be trusted as the wall. validate_identifierscompares against an allow-listset. The structured tool passes the caller’s table and column names through this before composing any SQL, so an unknown or malicious identifier is rejected by name rather than quoted into a query.
Step 8: The server and its tools
Four tools. list_tables and describe_table let a client discover the shape of
the database. query_table is the safe, structured path: allow-listed table,
allow-listed columns, parameterized filter values, and a capped row count, with
the SQL composed by psycopg so identifiers are quoted correctly. run_query is
the escape hatch for real SQL, wrapped in the guard, the read-only session, and
the same row cap.
Create the file
touch server.py
Add the code: server.py
"""A safe read-only Postgres MCP server.
Tools:
* list_tables -> the allow-listed tables this server will expose.
* describe_table -> column names and types for one allow-listed table.
* query_table -> structured SELECT with allow-listed columns, parameterized
filters, and a row cap. No raw SQL from the caller.
* run_query -> a guarded raw SELECT for real queries, still read-only,
timeout-bounded, and row-capped.
"""
import psycopg
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from psycopg import sql
import db
from config import settings
from safety import QueryError, assert_select_only, validate_identifiers
mcp = FastMCP("macmcp")
def _columns_of(table: str) -> list[dict]:
"""Return [{name, type}] for an allow-listed table, or raise QueryError."""
validate_identifiers([table], set(settings.allowed_tables), "table")
with db.connect() as conn:
rows = conn.execute(
"SELECT column_name AS name, data_type AS type "
"FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = %s "
"ORDER BY ordinal_position",
(table,),
).fetchall()
if not rows:
raise QueryError(f"unknown table: {table!r}")
return rows
def _cap(rows: list[dict], limit: int) -> dict:
"""Truncate to `limit` rows and report whether more existed.
Callers over-fetch one extra row (limit + 1) so a full result signals
truncation instead of silently returning a short answer.
"""
truncated = len(rows) > limit
return {"rows": rows[:limit], "truncated": truncated}
@mcp.tool
def list_tables() -> list[str]:
"""List the tables this server is allowed to read."""
return list(settings.allowed_tables)
@mcp.tool
def describe_table(table: str) -> list[dict]:
"""Return column names and types for one allowed table."""
try:
return _columns_of(table)
except QueryError as exc:
raise ToolError(str(exc))
@mcp.tool
def query_table(
table: str,
columns: list[str] | None = None,
filters: dict[str, str] | None = None,
limit: int | None = None,
) -> dict:
"""Select from one allowed table with allow-listed columns and filters.
columns defaults to all columns. filters is column -> value, combined with
AND and passed as parameters (never interpolated). limit is capped by
MAX_ROWS.
"""
filters = filters or {}
try:
real_columns = [c["name"] for c in _columns_of(table)]
allowed = set(real_columns)
selected = columns or real_columns
validate_identifiers(selected, allowed, "column")
validate_identifiers(list(filters), allowed, "column")
except QueryError as exc:
raise ToolError(str(exc))
row_limit = settings.max_rows if limit is None else min(limit, settings.max_rows)
query = sql.SQL("SELECT {cols} FROM {tbl}").format(
cols=sql.SQL(", ").join(sql.Identifier(c) for c in selected),
tbl=sql.Identifier(table),
)
params: list = []
if filters:
conditions = [
sql.SQL("{col} = %s").format(col=sql.Identifier(col)) for col in filters
]
query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
params.extend(filters.values())
query += sql.SQL(" LIMIT %s")
params.append(row_limit + 1) # one extra row detects truncation
try:
with db.connect() as conn:
rows = conn.execute(query, params).fetchall()
except psycopg.Error as exc:
raise ToolError(f"database error: {exc}")
return _cap(rows, row_limit)
@mcp.tool
def run_query(query: str, params: list | None = None) -> dict:
"""Run a single read-only SELECT and return capped rows.
Pass values through `params` (with %s placeholders) rather than formatting
them into the query string.
"""
try:
sql_text = assert_select_only(query)
except QueryError as exc:
raise ToolError(str(exc))
try:
with db.connect() as conn:
cur = conn.execute(sql_text, params or [])
rows = cur.fetchmany(settings.max_rows + 1)
except psycopg.Error as exc:
raise ToolError(f"database error: {exc}")
return _cap(rows, settings.max_rows)
def main() -> None:
import os
if os.environ.get("MCP_TRANSPORT", "stdio").lower() == "http":
mcp.run(
transport="http",
host=os.environ.get("MCP_HOST", "127.0.0.1"),
port=int(os.environ.get("MCP_PORT", "8000")),
)
else:
mcp.run() # stdio
if __name__ == "__main__":
main()
Detailed breakdown
_columns_ofdoes double duty. It validates the table against the allow-list and returns the table’s real columns straight frominformation_schema, parameterized on the table name.query_tablereuses that list as the column allow-list, so you never maintain a separate hand-written list of column names.query_tablecomposes SQL withpsycopg.sql.sql.Identifierquotes each table and column name correctly (and safely), while every filter value is a%splaceholder bound as a parameter. Identifiers are checked against the allow-list first; values are never trusted into the string at all. This is the pattern to copy any time you must build SQL from dynamic pieces.- Truncation is detected by over-fetching one row. Each tool fetches one more
row than its effective limit —
max_rows + 1forrun_query,row_limit + 1forquery_table— and_captrims to that limit and setstruncated. The client learns the result was clipped instead of silently seeing a short answer. run_queryis the raw path, but it still goes throughassert_select_only, the read-only session fromdb.connect, and the row cap. Callers pass values inparams, so a filter value cannot alter the query.psycopg.Errorfrom a timeout or a rejected write becomes a cleanToolError.- Errors surface as
ToolError. FastMCP returns that to the client as a typed tool error rather than a stack trace, whether the cause was a bad identifier, a non-SELECT query, a timeout, or a write attempt.
Step 9: Prove the guards hold
Two test files. test_safety.py unit-tests the pure guards with no database.
test_server.py drives the tools through FastMCP’s in-memory client against the
running Postgres container and checks that reads work, writes are refused,
timeouts fire, the allow-list holds, and rows are capped.
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_safety.py tests/test_server.py pytest.ini
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
Add the code: tests/test_safety.py
"""Unit tests for the pure guards. No database required."""
import pytest
from safety import QueryError, assert_select_only, validate_identifiers
def test_accepts_plain_select():
assert assert_select_only("SELECT 1") == "SELECT 1"
def test_accepts_with_and_strips_trailing_semicolon():
assert assert_select_only("WITH t AS (SELECT 1) SELECT * FROM t;") == (
"WITH t AS (SELECT 1) SELECT * FROM t"
)
def test_rejects_empty():
with pytest.raises(QueryError):
assert_select_only(" ")
def test_rejects_multiple_statements():
with pytest.raises(QueryError):
assert_select_only("SELECT 1; DROP TABLE customers")
@pytest.mark.parametrize("bad", ["INSERT INTO t VALUES (1)", "UPDATE t SET x=1", "DELETE FROM t"])
def test_rejects_non_select(bad):
with pytest.raises(QueryError):
assert_select_only(bad)
def test_validate_identifiers_rejects_unknown():
with pytest.raises(QueryError):
validate_identifiers(["price"], {"id", "name"}, "column")
def test_validate_identifiers_accepts_known():
validate_identifiers(["id", "name"], {"id", "name", "city"}, "column")
Add the code: tests/test_server.py
"""Integration tests against the running Postgres container.
Requires `docker compose up -d --wait` first (or `make up`). If the database is
unreachable, the whole module is skipped with a clear message rather than
failing.
"""
from dataclasses import replace
import psycopg
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
import db
import server
@pytest.fixture(scope="module", autouse=True)
def require_database():
try:
with db.connect() as conn:
conn.execute("SELECT 1")
except psycopg.Error as exc:
pytest.skip(f"Postgres not reachable (run `make up`): {exc}")
async def test_list_and_describe():
async with Client(server.mcp) as client:
tables = (await client.call_tool("list_tables", {})).data
assert set(tables) == {"customers", "orders"}
cols = (await client.call_tool("describe_table", {"table": "customers"})).data
assert {c["name"] for c in cols} == {"id", "name", "email", "city"}
async def test_query_table_filters_are_parameterized():
async with Client(server.mcp) as client:
result = (
await client.call_tool(
"query_table",
{"table": "customers", "columns": ["id", "name"], "filters": {"city": "London"}},
)
).data
# No ORDER BY in query_table, so compare as a set, not a sequence.
assert {r["name"] for r in result["rows"]} == {"Ada Lovelace", "Alan Turing"}
assert result["truncated"] is False
async def test_query_table_limit_is_exact():
"""An explicit limit returns exactly that many rows (plus a truncated flag)."""
async with Client(server.mcp) as client:
result = (
await client.call_tool(
"query_table", {"table": "orders", "columns": ["id"], "limit": 3}
)
).data
assert len(result["rows"]) == 3 # 5 orders exist, limit caps to 3
assert result["truncated"] is True
async def test_query_table_rejects_unknown_table_and_column():
async with Client(server.mcp) as client:
with pytest.raises(ToolError):
await client.call_tool("query_table", {"table": "secrets"})
with pytest.raises(ToolError):
await client.call_tool(
"query_table", {"table": "customers", "columns": ["password"]}
)
async def test_run_query_reads_and_caps_rows(monkeypatch):
# Settings is a frozen dataclass, so rebind the module's `settings` to a
# copy with a smaller cap rather than mutating the instance.
monkeypatch.setattr(server, "settings", replace(server.settings, max_rows=3))
async with Client(server.mcp) as client:
result = (
await client.call_tool(
"run_query", {"query": "SELECT id FROM orders ORDER BY id"}
)
).data
assert [r["id"] for r in result["rows"]] == [1, 2, 3]
assert result["truncated"] is True # 5 orders, capped at 3
async def test_run_query_parameters_prevent_injection():
async with Client(server.mcp) as client:
injection = "London'; DROP TABLE customers; --"
result = (
await client.call_tool(
"run_query",
{"query": "SELECT count(*) AS n FROM customers WHERE city = %s",
"params": [injection]},
)
).data
assert result["rows"][0]["n"] == 0 # no such city; nothing dropped
async def test_write_is_rejected():
async with Client(server.mcp) as client:
with pytest.raises(ToolError):
await client.call_tool(
"run_query", {"query": "SELECT 1; INSERT INTO customers VALUES (9)"}
)
async def test_direct_write_is_refused_by_postgres():
"""Even bypassing the guard, the read-only role/transaction refuses a write."""
with db.connect() as conn:
with pytest.raises(psycopg.Error):
conn.execute("INSERT INTO customers (id, name, email, city) "
"VALUES (99, 'x', 'x@x', 'x')")
async def test_statement_timeout_cancels_slow_query(monkeypatch):
"""A deliberately slow query is cancelled by statement_timeout."""
monkeypatch.setattr(db, "settings", replace(db.settings, statement_timeout_ms=500))
async with Client(server.mcp) as client:
with pytest.raises(ToolError):
await client.call_tool("run_query", {"query": "SELECT pg_sleep(3)"})
Detailed breakdown
require_databaseskips the whole module if Postgres is not up, so the suite fails loudly on a real bug but never on a missing container.make testbrings the database up first.test_query_table_filters_are_parameterizedconfirms the London filter returns exactly Ada and Alan (compared as a set, sincequery_tableadds noORDER BY), proving the structured path works and the value is bound, not interpolated.test_query_table_limit_is_exactthen checks that an explicitlimitreturns exactly that many rows withtruncatedset.test_run_query_parameters_prevent_injectionsends a classic injection string as a parameter. Because it is bound, Postgres treats the whole thing as a city name, finds no match, returns0, andcustomersis untouched.test_write_is_rejectedgoes through the guard (multi-statement), whiletest_direct_write_is_refused_by_postgresdeliberately skips the guard and runs a bareINSERTon the connection — and Postgres still refuses it. That is the layered defense demonstrated: the write fails even when Python does not stop it.test_statement_timeout_cancels_slow_querylowers the timeout to 500 ms and runspg_sleep(3); Postgres cancels it and the tool raises.Settingsis a frozen dataclass, so the two limit-tweaking tests rebind the module’ssettingsto adataclasses.replacecopy rather than mutating the instance (which would raiseFrozenInstanceError).
Run everything:
make up
make test
You should see all tests pass (the timeout test takes about half a second).
Step 10: A demo script
A short script that drives the tools the way a client would, so you can see real output without wiring up an MCP client.
Create the file
mkdir -p scripts
touch scripts/demo.py
Add the code: scripts/demo.py
"""Drive the tools in-memory against the running database and print results."""
import asyncio
import json
from fastmcp import Client
import server
async def main() -> None:
async with Client(server.mcp) as client:
tables = (await client.call_tool("list_tables", {})).data
print("tables:", tables)
london = (
await client.call_tool(
"query_table",
{"table": "customers", "columns": ["name", "city"],
"filters": {"city": "London"}},
)
).data
print("London customers:", json.dumps(london["rows"]))
revenue = (
await client.call_tool(
"run_query",
{"query": "SELECT sum(total_cents) AS paid_cents "
"FROM orders WHERE status = %s", "params": ["paid"]},
)
).data
print("paid revenue (cents):", revenue["rows"][0]["paid_cents"])
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
- The script uses the same in-memory
Clientas the tests, so it exercises the real tools and the real database without an external MCP host. - The revenue query filters on
status = %swith the value bound as a parameter, and sumstotal_centsfor paid orders. With the seed data that is1299 + 4500 + 15000 = 20799cents.
Run it:
make demo
Step 11: The Makefile
Wrap credential setup, the database lifecycle, the tests, and the demo. Plain
make prints help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
COMPOSE := docker compose
PSQL_SUPER := $(COMPOSE) exec -T db psql -U postgres -d appdb
.PHONY: help setup up down reset psql serve test demo logs clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-8s\033[0m %s\n", $$1, $$2}'
# Create .env from the committed template on first use. Real credentials live in
# .env (gitignored); .env.example is the checked-in placeholder.
.env:
@cp .env.example .env
@echo "Created .env from .env.example (dev defaults; edit for anything beyond local)."
setup: .env ## Create .env from .env.example if it is missing
@echo ".env is ready."
up: .env ## Start Postgres and wait until it is ready
$(COMPOSE) up -d --wait
down: ## Stop Postgres (keeps the data volume)
$(COMPOSE) down
reset: ## Stop Postgres and delete the data volume (re-runs init.sh next up)
$(COMPOSE) down -v
psql: ## Open a psql shell as the superuser
$(PSQL_SUPER)
serve: .env ## Run the MCP server over HTTP in the foreground (Ctrl-C to stop)
MCP_TRANSPORT=http uv run python server.py
test: up ## Bring the database up, then run the test suite
uv run pytest -v
demo: up ## Run the demo script against the database
uv run python -m scripts.demo
logs: ## Tail the Postgres container logs
$(COMPOSE) logs -f db
clean: ## Remove Python caches (keeps the database volume and .env)
rm -rf .pytest_cache __pycache__ tests/__pycache__
Detailed breakdown
- The
.envfile target creates.envfrom.env.exampleon first use.up,serve, andsetupdepend on it, somake upon a fresh checkout copies the template automatically and the stack always has its credentials. Because it is a file target, Make runs it only when.envdoes not already exist, so your edited copy is never overwritten. testanddemodepend onup, somake teston a fresh checkout creates.env, starts Postgres, waits for health, and only then runs. You never see a spurious failure from the database not being ready.resetusesdown -vto delete the named volume. That is the only way to re-runinit.sh, since the entrypoint skips initialization when the data directory already exists. Use it after editing the schema or seed data.cleankeeps the database volume and.envand only removes Python caches; wiping data isreset’s job, and the two are kept separate on purpose.psqlreaches Postgres through the container, so you can inspect the database without installing a client on the host.
Try it against a real client
Point any MCP client at the server over stdio. For Claude Desktop, add this to
claude_desktop_config.json (adjust the path):
{
"mcpServers": {
"postgres-readonly": {
"command": "uv",
"args": ["run", "python", "server.py"],
"cwd": "/absolute/path/to/readonly-postgres-mcp-server-macos"
}
}
}
Make sure the database is up (make up) before the client launches the server.
Ask the client to “list the tables,” “describe the orders table,” or “show total
paid revenue,” and watch it use list_tables, describe_table, and run_query
in turn — with every write and every runaway query refused underneath.
Recap
You built a Postgres MCP server that is read-only by construction, not by
convention. The safety comes from independent layers: a SELECT-only role, a
read-only transaction, a statement timeout, an allow-list for identifiers,
parameterized values, and a row cap. Any one of them failing still leaves the
others standing, which is the point — a database tool for a language model should
assume the caller will, eventually, ask it to do the wrong thing.
Next improvements
- Per-table column allow-lists for masking sensitive columns (e.g. hide
email) rather than exposing every column of an allowed table. - A connection pool (
psycopg_pool) if the server handles concurrent clients, keeping the same read-only options on every pooled connection. - Query cost limits via
EXPLAINbefore execution, rejecting plans whose estimated cost exceeds a threshold, to catch expensive queries the timeout would only catch after the fact. - Audit logging of every executed statement and its caller, so read access is reviewable.