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 SELECT and 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.example template) so no password lives in the source, the compose file, or the SQL.
  • A docker-compose.yml running postgres:17 with a seeded schema and a SELECT-only role, created before any table is granted out.
  • A config.py that reads connection, allow-list, timeout, and row-cap settings from the environment.
  • A db.py that opens read-only, timeout-bounded connections.
  • A safety.py with the query guard and identifier validation, unit-tested with no database required.
  • A server.py exposing four tools: list_tables, describe_table, query_table (allow-list driven), and run_query (guarded raw SELECT).
  • A pytest suite 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 --version and a running daemon (docker ps).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • Basic SQL familiarity. You do not need psql on the host — the Makefile reaches Postgres through docker 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

  • .env is 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 .env is 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.example is committed; .env is 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 inject DATABASE_URL from 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_URL reuses ${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 in DATABASE_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 .env automatically from the project directory and substitutes ${POSTGRES_PASSWORD} and ${MCP_READONLY_PASSWORD}. The :?... form makes docker compose up fail 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.sh is 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 .sh script (rather than a .sql file) is used so it can read MCP_READONLY_PASSWORD from 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_isready so docker compose up --wait blocks until Postgres actually accepts connections, not just until the container starts.
  • pgdata is a named volume, so data survives make down and is wiped only by make 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, and CREATE 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 LOGIN so the server can connect as it. It is granted CONNECT, USAGE on the schema, and SELECT on the tables. It is never granted INSERT, UPDATE, DELETE, TRUNCATE, or any DDL, so Postgres rejects those outright regardless of what SQL reaches it.
  • ALTER DEFAULT PRIVILEGES means tables you add to public later are also readable by the role without re-running a grant — but the ceiling stays at SELECT.
  • 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 = 20799 cents.

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 local libpq. It is the maintained successor to psycopg2 and has first-class support for safe SQL composition, which the allow-list tool relies on.
  • python-dotenv loads the .env file into the process environment so the server picks up DATABASE_URL in local development. It does not override variables already set in the environment, so it is inert in production.
  • fastmcp is the MCP server framework. pytest-asyncio lets 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 .env into the process environment on import, so a locally-run server or test picks up DATABASE_URL. It does not override variables already present, so in production the platform’s injected values win and the (absent) .env is ignored.
  • database_url has 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.example rather than starting misconfigured.
  • allowed_tables is a tuple parsed from a comma-separated variable. The structured query_table tool refuses anything outside it. Defaulting to the two seeded tables keeps the tutorial runnable; in production you set it explicitly.
  • statement_timeout_ms and max_rows are the runaway-query guards. They are integers read from the environment so an operator can tighten them without touching code.
  • frozen=True makes 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 options string sets GUC parameters at startup. -c default_transaction_read_only=on makes every transaction on this connection read-only; Postgres raises ReadOnlySqlTransaction on any write. -c statement_timeout=<ms> cancels any statement exceeding the limit with QueryCanceled. 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=True means each execute runs 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_row returns each row as a plain dict, 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_URL at 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_only trims 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 be SELECT or WITH.
  • It is deliberately honest about its limits. WITH x AS (...) DELETE ... is a valid statement that starts with WITH, so a keyword check alone cannot guarantee read-only. That write is still refused, by the read-only transaction and the SELECT-only role. The guard exists to return a clean error fast, not to be trusted as the wall.
  • validate_identifiers compares against an allow-list set. 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_of does double duty. It validates the table against the allow-list and returns the table’s real columns straight from information_schema, parameterized on the table name. query_table reuses that list as the column allow-list, so you never maintain a separate hand-written list of column names.
  • query_table composes SQL with psycopg.sql. sql.Identifier quotes each table and column name correctly (and safely), while every filter value is a %s placeholder 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 + 1 for run_query, row_limit + 1 for query_table — and _cap trims to that limit and sets truncated. The client learns the result was clipped instead of silently seeing a short answer.
  • run_query is the raw path, but it still goes through assert_select_only, the read-only session from db.connect, and the row cap. Callers pass values in params, so a filter value cannot alter the query. psycopg.Error from a timeout or a rejected write becomes a clean ToolError.
  • 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_database skips the whole module if Postgres is not up, so the suite fails loudly on a real bug but never on a missing container. make test brings the database up first.
  • test_query_table_filters_are_parameterized confirms the London filter returns exactly Ada and Alan (compared as a set, since query_table adds no ORDER BY), proving the structured path works and the value is bound, not interpolated. test_query_table_limit_is_exact then checks that an explicit limit returns exactly that many rows with truncated set.
  • test_run_query_parameters_prevent_injection sends a classic injection string as a parameter. Because it is bound, Postgres treats the whole thing as a city name, finds no match, returns 0, and customers is untouched.
  • test_write_is_rejected goes through the guard (multi-statement), while test_direct_write_is_refused_by_postgres deliberately skips the guard and runs a bare INSERT on 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_query lowers the timeout to 500 ms and runs pg_sleep(3); Postgres cancels it and the tool raises. Settings is a frozen dataclass, so the two limit-tweaking tests rebind the module’s settings to a dataclasses.replace copy rather than mutating the instance (which would raise FrozenInstanceError).

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 Client as the tests, so it exercises the real tools and the real database without an external MCP host.
  • The revenue query filters on status = %s with the value bound as a parameter, and sums total_cents for paid orders. With the seed data that is 1299 + 4500 + 15000 = 20799 cents.

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 .env file target creates .env from .env.example on first use. up, serve, and setup depend on it, so make up on 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 .env does not already exist, so your edited copy is never overwritten.
  • test and demo depend on up, so make test on 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.
  • reset uses down -v to delete the named volume. That is the only way to re-run init.sh, since the entrypoint skips initialization when the data directory already exists. Use it after editing the schema or seed data.
  • clean keeps the database volume and .env and only removes Python caches; wiping data is reset’s job, and the two are kept separate on purpose.
  • psql reaches 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 EXPLAIN before 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.