An assistant for a docs team could answer “how many reference pages do we have?” by writing SQL itself. That needs a tool that runs model-written SQL against a real database, and that SQL is untrusted input: the model may write a query that deletes rows, reads editor emails, runs for minutes or returns a million rows, because a user asked or because a page it read told it to. You will build that tool as an MCP server and give each kind of bad query a layer that stops it, with PostgreSQL 17, psycopg 3 and mcp 2.3.0.
When you need this. The moment an agent gets a database connection. A reporting assistant, an internal analytics bot and a release bot that looks up deploy history all run SQL that a model wrote. A prompt-injected page or an over-eager model can turn that into a write, a data leak or a runaway query, so each of those needs its own layer, and no single mistake should reach the data.
1. Set up the project and a throwaway cluster
Note: everything runs on a throwaway cluster: a PostgreSQL server whose data directory, port and roles exist only for this lesson and are deleted afterwards. The lesson_cluster.py helper connects to the cluster it creates; it does not discover or reuse an existing server.
Download the complete lab ZIP, which includes the Python files, fixtures, tests and README. Extract it before running the commands below.
unzip build-a-scoped-postgresql-mcp-tool-with-read-only-roles-and-query-limits.zip
cd mcp-postgres-readonly
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements-lock.txt
export PG_BIN="$(pg_config --bindir)"
"$PG_BIN/postgres" --versionlesson_cluster.py runs initdb with random passwords and scram-sha-256 authentication, starts the server on localhost only with no Unix socket, and seeds it. destroy() stops the server and deletes the directory, but only if the directory holds the admin-password file that create() writes first; it refuses an empty path, the current directory and your home directory.
"""Create and remove a throwaway PostgreSQL 17 cluster for this lesson.
Usage:
python lesson_cluster.py create DATA_DIR [--port 54329] # prints the reader DSN
python lesson_cluster.py destroy DATA_DIR
The cluster lives entirely in DATA_DIR, listens on localhost only, has no Unix
socket and uses scram-sha-256 passwords. It never touches another cluster or a
Homebrew service. Set PG_BIN if the PostgreSQL 17 binaries are not in
/opt/homebrew/opt/postgresql@17/bin.
"""
import argparse
import os
import re
import secrets
import shutil
import socket
import subprocess
from pathlib import Path
import psycopg
from psycopg import sql
HERE = Path(__file__).resolve().parent
PG_BIN = Path(os.environ.get("PG_BIN", "/opt/homebrew/opt/postgresql@17/bin"))
DATABASE = "docs_lesson"
ADMIN, OWNER, READER = "lesson_admin", "docs_owner", "mcp_reader"
ENV = {**os.environ, "LC_ALL": "C"} # the macOS postmaster refuses to start without a valid locale
def dsn(user, password, port, database=DATABASE):
return f"host=localhost port={port} dbname={database} user={user} password={password}"
def free_port():
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
def server_version():
output = subprocess.run([PG_BIN / "postgres", "--version"], capture_output=True, text=True, check=True).stdout
match = re.search(r"\b(\d+)\.(\d+)", output)
if not match or match[1] != "17":
raise SystemExit(f"This lesson needs PostgreSQL 17; found: {output.strip()}")
return f"{match[1]}.{match[2]}"
class LessonCluster:
"""A cluster in data_dir. create() starts and seeds it; destroy() stops it and deletes data_dir."""
def __init__(self, data_dir, port=None):
if not str(data_dir).strip():
raise SystemExit("give the cluster a directory; an empty path is refused")
self.data_dir = Path(data_dir).expanduser().resolve()
if self.data_dir in (Path.cwd(), Path.home(), Path(self.data_dir.anchor)):
raise SystemExit(f"refusing to use {self.data_dir} as a cluster directory")
self.port = port or free_port()
self.admin_password = secrets.token_urlsafe(18)
self.reader_password = secrets.token_urlsafe(18)
@property
def reader_dsn(self):
return dsn(READER, self.reader_password, self.port)
def admin_dsn(self, database=DATABASE):
return dsn(ADMIN, self.admin_password, self.port, database)
def create(self):
self.version = server_version()
if self.data_dir.exists():
raise SystemExit(f"{self.data_dir} already exists; destroy it first")
try:
self._create()
except BaseException:
self.destroy() # never leave a running server behind a deleted data directory
raise
return self
def _create(self):
self.data_dir.mkdir(parents=True)
password_file = self.data_dir / "admin-password"
password_file.write_text(self.admin_password + "\n")
password_file.chmod(0o600)
subprocess.run([PG_BIN / "initdb", "-D", self.data_dir / "data", "-U", ADMIN, "--auth=scram-sha-256",
f"--pwfile={password_file}", "-E", "UTF8", "--locale=C"],
check=True, capture_output=True, env=ENV)
options = f"-p {self.port} -c listen_addresses=localhost -c unix_socket_directories=''"
subprocess.run([PG_BIN / "pg_ctl", "-D", self.data_dir / "data", "-o", options,
"-l", self.data_dir / "server.log", "-w", "start"], check=True, capture_output=True, env=ENV)
self.seed()
def seed(self):
with psycopg.connect(self.admin_dsn("postgres"), autocommit=True) as admin:
admin.execute(f"CREATE ROLE {OWNER} NOLOGIN")
admin.execute(sql.SQL("CREATE ROLE {} LOGIN CONNECTION LIMIT 5 PASSWORD {}").format(
sql.Identifier(READER), sql.Literal(self.reader_password)))
admin.execute(f"CREATE DATABASE {DATABASE} OWNER {OWNER}")
with psycopg.connect(self.admin_dsn(), autocommit=True) as admin:
admin.execute(f"SET ROLE {OWNER}")
admin.execute((HERE / "schema.sql").read_text())
admin.execute("RESET ROLE")
admin.execute((HERE / "grants.sql").read_text())
def destroy(self):
"""Stop the server and delete data_dir, but only if data_dir looks like a lesson cluster."""
if not self.data_dir.exists():
return
if any(self.data_dir.iterdir()) and not (self.data_dir / "admin-password").is_file():
raise SystemExit(f"refusing to delete {self.data_dir}: it is not a lesson cluster (no admin-password file)")
if (self.data_dir / "data" / "postmaster.pid").exists():
subprocess.run([PG_BIN / "pg_ctl", "-D", self.data_dir / "data", "-m", "fast", "-w", "stop"],
check=True, capture_output=True, env=ENV)
shutil.rmtree(self.data_dir)
def __enter__(self):
return self.create()
def __exit__(self, *exc):
self.destroy()
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("command", choices=["create", "destroy"])
parser.add_argument("data_dir")
parser.add_argument("--port", type=int, default=54329)
options = parser.parse_args(argv)
cluster = LessonCluster(options.data_dir, options.port)
if options.command == "create":
cluster.create()
dsn_file = cluster.data_dir / "reader.dsn"
dsn_file.write_text(cluster.reader_dsn + "\n")
dsn_file.chmod(0o600)
print(f"PostgreSQL {cluster.version} on localhost:{cluster.port}; reader DSN saved in {dsn_file}")
else:
cluster.destroy()
print(f"stopped and removed {cluster.data_dir}")
if __name__ == "__main__":
main()To work with a cluster by hand, pick an unused port. The ${PG_DIR:?} form stops the shell if the variable is empty:
PG_DIR="$(mktemp -d)/cluster"
.venv/bin/python lesson_cluster.py create "$PG_DIR" --port 54329
export DOCS_READER_DSN="$(cat "$PG_DIR/reader.dsn")"
# ... use the server ...
.venv/bin/python lesson_cluster.py destroy "${PG_DIR:?set PG_DIR first}"destroy given an empty path or a folder that is not a cluster. LessonCluster/destroy() refuse without deleting it. Use the path returned by create() and retain the marker-file and dangerous-path checks.
2. Grant the tool’s role one table
schema.sql creates schema docs with a table of editors and a table of 30 pages, and runs as docs_owner, a role that cannot log in:
-- Lesson schema and seed rows. lesson_cluster.py runs this as the owner role, docs_owner.
CREATE SCHEMA docs;
CREATE TABLE docs.editors (
editor_id text PRIMARY KEY,
email text NOT NULL
);
CREATE TABLE docs.pages (
slug text PRIMARY KEY,
editor_id text NOT NULL REFERENCES docs.editors,
section text NOT NULL CHECK (section IN ('guides', 'reference', 'changelog')),
words integer NOT NULL CHECK (words >= 0)
);
INSERT INTO docs.editors VALUES
('e-1', 'ana@example.com'),
('e-2', 'bo@example.com');
-- 30 deterministic pages: page-01 ... page-30.
INSERT INTO docs.pages
SELECT format('page-%s', lpad(n::text, 2, '0')),
CASE WHEN n % 2 = 0 THEN 'e-1' ELSE 'e-2' END,
(ARRAY['guides', 'reference', 'changelog'])[n % 3 + 1],
200 + n * 25
FROM generate_series(1, 30) AS n;The tool logs in as mcp_reader. grants.sql explicitly grants CONNECT on the database, USAGE on schema docs and SELECT on docs.pages. These grants block table writes and reads of docs.editors, including when SQL reaches PostgreSQL without the tool limits. They do not restrict every function or system-catalog read, as the checks below show. Since PostgreSQL 15, ordinary roles also cannot create objects in the public schema by default. PostgreSQL 17: privileges · schemas and privileges
-- Privileges for the MCP tool's login role. lesson_cluster.py runs this as the cluster administrator.
-- The reader may connect to this database, see schema docs and read docs.pages. It can still
-- read the system catalogs (pg_catalog), as every role can; it cannot read other tables or write.
REVOKE ALL ON DATABASE docs_lesson FROM PUBLIC;
REVOKE CONNECT ON DATABASE postgres FROM PUBLIC;
REVOKE CONNECT ON DATABASE template1 FROM PUBLIC;
GRANT CONNECT ON DATABASE docs_lesson TO mcp_reader;
GRANT USAGE ON SCHEMA docs TO mcp_reader;
GRANT SELECT ON docs.pages TO mcp_reader;
-- Session defaults for the reader. A session can change these with SET, so the tool also
-- sets them for every transaction; the GRANTs above are the boundary.
ALTER ROLE mcp_reader IN DATABASE docs_lesson SET default_transaction_read_only = on;
ALTER ROLE mcp_reader IN DATABASE docs_lesson SET statement_timeout = '500ms';The ALTER ROLE ... SET lines are session defaults, not limits: a session can change them with SET. Table privileges also do not cover every write. With its read-only default turned off, the recorded reader created a large object with lo_create(), an object with its own ownership and privileges. PostgreSQL stores its data as chunks in database rows; the grants on docs.pages do not govern that large-object API. The next step adds transaction limits for these cases. Large-object implementation · ALTER ROLE
SELECT on the editors table. PostgreSQL returns 42501. The role may SELECT docs.pages, not docs.editors; grant only the data the tool should expose.
Table write with every tool limit removed. The recorded direct connection still gets 42501. Preserve the table grants even when a tool also validates SQL.
Large object created with every tool limit removed. The direct role can create it because table grants do not cover large objects. This is why the tool adds a read-only transaction rather than calling its table grants a complete write boundary.
3. Run each query under transaction limits
The role is the outer wall; now we add the limits inside each call.
42601- not one SELECT or VALUES
0A000- not allowed in a cursor
25006- read-only transaction
42501- no privilege
57014- over 500 ms
"""Run one model-written SQL query under read-only limits.
run_readonly_query() opens a new connection as the read-only role, starts a
READ ONLY transaction, sets a statement timeout for that transaction, and runs
the query through a named server-side cursor. PostgreSQL keeps the result on
the server; the tool fetches at most max_rows + 1 rows. The role's privileges
are the real boundary; these limits narrow what a query can do within them.
"""
import datetime
import decimal
import json
import psycopg
MAX_ROWS_LIMIT = 200
DEFAULT_MAX_ROWS = 50
TIMEOUT_MS = 500
MAX_RESULT_BYTES = 200_000 # JSON size of the returned rows
# SQLSTATE codes this tool expects, with the reason it reports.
REASONS = {
"25006": "the transaction is read-only",
"42501": "the role lacks the privilege",
"57014": f"the query ran longer than {TIMEOUT_MS} ms",
"42601": "only one SELECT or VALUES query is allowed",
"0A000": "the query uses a feature a cursor does not allow, such as a data-modifying WITH",
"42P01": "the table does not exist or is not visible",
"42703": "a column does not exist",
}
WRITE_CHECK = """
SELECT r.rolsuper, r.rolcreaterole, r.rolcreatedb,
pg_has_role(current_user, 'pg_write_all_data', 'USAGE'),
EXISTS (SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_toast%'
AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
AND (has_table_privilege(current_user, c.oid, 'INSERT, UPDATE, DELETE, TRUNCATE')
OR has_any_column_privilege(current_user, c.oid, 'INSERT, UPDATE'))),
EXISTS (SELECT 1 FROM pg_namespace n
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_toast%'
AND has_schema_privilege(current_user, n.oid, 'CREATE')),
has_database_privilege(current_user, current_database(), 'CREATE, TEMP'),
EXISTS (SELECT 1 FROM pg_auth_members m WHERE m.member = r.oid)
FROM pg_roles r WHERE r.rolname = current_user
"""
WRITE_PROBLEMS = ["is a superuser", "can create roles", "can create databases", "is a member of pg_write_all_data",
"can change rows or columns of a table", "can create objects in a schema",
"can create schemas or temporary tables in the database",
"is a member of another role, which SET ROLE could switch to"]
class QueryRejected(Exception):
"""The query did not run, or PostgreSQL stopped it."""
def __init__(self, reason, sqlstate=None):
super().__init__(reason)
self.reason, self.sqlstate = reason, sqlstate
def to_json_value(value):
"""Turn a database value into something json.dumps can write."""
if isinstance(value, (datetime.date, datetime.datetime, decimal.Decimal)):
return str(value)
return value
def run_readonly_query(reader_dsn, sql, max_rows=DEFAULT_MAX_ROWS):
if not isinstance(sql, str) or not sql.strip():
raise QueryRejected("the query is empty")
if not isinstance(max_rows, int) or isinstance(max_rows, bool) or not 1 <= max_rows <= MAX_ROWS_LIMIT:
raise QueryRejected(f"max_rows must be an integer from 1 to {MAX_ROWS_LIMIT}")
try:
# A new connection per call: a SET or set_config() from an earlier query cannot carry over.
with psycopg.connect(reader_dsn, connect_timeout=5) as conn:
with conn.transaction():
conn.execute("SET TRANSACTION READ ONLY")
conn.execute("SELECT set_config('statement_timeout', %s, true)", (f"{TIMEOUT_MS}ms",))
# A named cursor is a server-side DECLARE ... CURSOR FOR <query>: PostgreSQL accepts one
# SELECT or VALUES query, and keeps the result on the server until it is fetched.
with conn.cursor(name="run_query") as cursor:
cursor.execute(sql)
rows = cursor.fetchmany(max_rows + 1)
columns = [column.name for column in cursor.description]
except psycopg.Error as error:
state = error.sqlstate
raise QueryRejected(REASONS.get(state, "PostgreSQL rejected the query"), state) from None
result_rows, size = [], 0
for row in rows[:max_rows]:
values = [to_json_value(value) for value in row]
size += len(json.dumps(values, default=str))
if size > MAX_RESULT_BYTES:
# The rows were already fetched, so this bounds what the model receives, not what PostgreSQL sent.
raise QueryRejected(f"the result is larger than {MAX_RESULT_BYTES} bytes; select fewer or shorter values")
result_rows.append(values)
return {"columns": columns, "rows": result_rows, "truncated": len(rows) > max_rows}
def visible_columns(reader_dsn):
"""Columns the reader can see. information_schema shows only objects the role has privileges on."""
with psycopg.connect(reader_dsn, connect_timeout=5) as conn:
rows = conn.execute(
"SELECT table_schema, table_name, column_name, data_type FROM information_schema.columns "
"WHERE table_schema NOT IN ('pg_catalog', 'information_schema') "
"ORDER BY table_schema, table_name, ordinal_position").fetchall()
return [{"table": f"{schema}.{table}", "column": column, "type": kind} for schema, table, column, kind in rows]
def write_problems(reader_dsn):
"""Ways the connected role could change data or objects in this database; an empty list means none found."""
with psycopg.connect(reader_dsn, connect_timeout=5) as conn:
flags = conn.execute(WRITE_CHECK).fetchone()
return [problem for problem, flag in zip(WRITE_PROBLEMS, flags) if flag]
def check_reader(reader_dsn):
"""Refuse to serve with a role that could write."""
problems = write_problems(reader_dsn)
if problems:
raise SystemExit("refusing to serve: the database role " + "; ".join(problems))run_readonly_query() checks the arguments, then opens a new connection per call, so a setting changed by an earlier query cannot carry over. Inside one transaction it stacks four limits:
SET TRANSACTION READ ONLYrefuses writes to non-temporary tables withINSERT,UPDATEorDELETE, allCREATE,ALTERandDROPcommands, and writes through functions such aslo_create(). The documentation calls this “a high-level notion of read-only”, not a guarantee against every write to disk. SET TRANSACTIONset_config('statement_timeout', '500ms', true)cancels a statement after 500 ms;truelimits the setting to this transaction. statement_timeoutconn.cursor(name="run_query")makes a server-side cursor, which psycopg runs asDECLARE ... CURSOR FORthe model’s SQL.DECLAREaccepts oneSELECTorVALUESquery, so anINSERTor a second statement fails before anything runs, and the result stays on the server until it is fetched. DECLARE · psycopg: server-side cursorsfetchmany(max_rows + 1)pulls only the rows the tool can return, plus one to reporttruncated. A row count does not bound bytes, so the function also rejects returned row values whose serialized JSON totals more than 200,000 bytes. This budget excludes column names and response-wrapper fields.
Every psycopg error becomes QueryRejected with its five-character SQLSTATE. PostgreSQL recommends that applications test the code rather than the message, which can change and is translated. PostgreSQL error codes
check_reader() runs once when the server starts. It asks PostgreSQL whether the connected role could write in any way the check knows about: superuser, role or database creation, pg_write_all_data, table or column write privileges, including inherited ones, CREATE or TEMP, or membership in any other role, which SET ROLE could switch to. A wrong connection string then fails at start-up instead of handing the model a writable role.
Diagnose query rejection at the right boundary
Separate query shape, transaction permissions and resource limits. A valid SELECT can still exceed its deadline or response budget. A role with unexpected write grants must fail the startup check before it reaches a model.
Inspect the recorded refusal cases
INSERT through the tool. The server-side cursor rejects it with 42601; expose a separate reviewed write tool instead of loosening this query endpoint.
DELETE inside a WITH clause. The cursor rejects the data-modifying query with 0A000. Checking only the first SQL keyword would miss this case; let PostgreSQL parse it.
Two statements in one call. DECLARE rejects the second statement with 42601. Send one SELECT or VALUES query per call.
SELECT that calls a writing function. lo_create() passes the cursor’s query shape, but the read-only transaction refuses it with 25006. Keep the transaction check as well as the cursor.
Query that runs longer than 500 ms. The statement timeout cancels it with 57014. Narrow or index the query rather than removing the per-call timeout.
Three million rows with max_rows 1. The test fetches one row with truncated: true and checks client-memory growth stays below 64 MB. Keep the server-side cursor: slicing a fully fetched result would already have allocated it.
One 300,000-character value, or 200 rows of 2,000 characters. The 200,000-byte JSON budget raises QueryRejected without a SQLSTATE. Select smaller values or fewer columns; a row cap alone cannot bound response size.
A notification with pg_notify. The call is allowed: a notification is not a database write. If signals to other sessions are outside your policy, this role/transaction combination needs an additional function-permission boundary.
Role defaults reset by an administrator. The tool still produces 25006 and 57014, because it sets its own transaction limits. Keep those statements in each call rather than relying on ALTER ROLE defaults.
A session setting changed by an earlier call. The next writing function still fails with 25006. Keep the new connection per call, or explicitly reset pooled connections before reuse.
Role granted pg_write_all_data, owner membership, membership without inherit or column UPDATE. check_reader() refuses startup for the tested grants. Fix the role or connection string; do not suppress that failure and let the model use a writable identity.
Know what these layers do not cover. The reader can read every row of docs.pages: a role limits tables, not rows, so per-caller access needs row-level security policies or a view per caller, plus a way to pass the caller’s identity. Like every role, it can query the system catalogs, which reveal that docs.editors exists. A 500 ms query can still use CPU, memory and temporary disk on the server, and one large value still crosses the connection before the byte budget rejects it. The reader can call pg_notify to signal any session that listens on a channel.
Rows that come back are untrusted too. A text column can hold a sentence written to steer the model, so treat query results like any other tool result before the model acts on them.
4. Expose the query as an MCP tool
"""An MCP server that lets a model run read-only SQL against the lesson database.
Usage: DOCS_READER_DSN="host=localhost port=54329 ..." python postgres_server.py
The DSN must name the read-only role created by lesson_cluster.py. At start-up the
server refuses a role with any write capability its check knows about: superuser,
role or database creation, pg_write_all_data, table or column write privileges, or
CREATE or TEMP privileges. Built with the official MCP
Python SDK (mcp 2.3.0) and psycopg 3.
"""
import json
import os
import sys
from typing import Annotated, Any, TypedDict
from pydantic import Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
from readonly_query import DEFAULT_MAX_ROWS, MAX_ROWS_LIMIT, QueryRejected, check_reader, run_readonly_query, visible_columns
class QueryResult(TypedDict):
columns: list[str]
rows: list[list[Any]]
truncated: bool
def build_server(reader_dsn):
server = MCPServer("docs-sql", version="1.0.0",
instructions="Read-only SQL over schema docs. Read postgres://docs/schema for the visible columns.")
@server.tool(title="Run a read-only SQL query", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
def run_query(sql: Annotated[str, Field(min_length=1, max_length=10_000)],
max_rows: Annotated[int, Field(ge=1, le=MAX_ROWS_LIMIT, strict=True)] = DEFAULT_MAX_ROWS) -> QueryResult:
"""Run one SELECT or VALUES query on the docs database. Returns at most max_rows rows (200 at most) and 200,000 bytes; each query stops after 500 ms."""
try:
return run_readonly_query(reader_dsn, sql, max_rows)
except QueryRejected as rejected:
code = f" (SQLSTATE {rejected.sqlstate})" if rejected.sqlstate else ""
raise ToolError(f"Query rejected: {rejected.reason}{code}.") from None
@server.resource("postgres://docs/schema", title="Visible tables and columns", mime_type="application/json")
def schema() -> str:
"""The tables and columns the read-only role can see."""
return json.dumps(visible_columns(reader_dsn), indent=2)
return server
if __name__ == "__main__":
dsn = os.environ.get("DOCS_READER_DSN")
if not dsn:
sys.exit("set DOCS_READER_DSN to the read-only role's connection string")
check_reader(dsn)
build_server(dsn).run()run_query returns the TypedDict QueryResult, so the SDK publishes an outputSchema and the result carries structuredContent. The Annotated types give the inputSchema a length limit for sql and a strict integer range of 1 to 200 for max_rows. A rejection becomes ToolError, a result with isError: true that tells the model which SQLSTATE stopped its query. The resource postgres://docs/schema lists the columns the reader can see, from information_schema.columns, which shows a role only the columns it has a privilege on. information_schema.columns
Empty SQL or max_rows outside 1 to 200. The function raises QueryRejected before connecting, without a SQLSTATE. Correct these arguments before retrying.
5. Record every layer and run the tests
record_results.py creates a cluster, runs the queries, connects as the reader directly with its read-only default turned off to test the role alone, calls the tool through the MCP SDK client, saves everything and deletes the cluster:
"""Create a throwaway cluster, run the lesson's queries and role checks, save the results, remove the cluster.
Usage: python record_results.py [--write query-results.json]
Needs PostgreSQL 17 (see lesson_cluster.py) and the pinned packages.
"""
import argparse
import asyncio
import json
import tempfile
from pathlib import Path
import psycopg
from mcp import Client
import postgres_server
from lesson_cluster import LessonCluster
from readonly_query import QueryRejected, run_readonly_query, visible_columns, write_problems
QUERIES = [
("first five pages", "SELECT slug, section, words FROM docs.pages ORDER BY slug", 5),
("pages per section", "SELECT section, count(*) AS pages FROM docs.pages GROUP BY section ORDER BY section", 50),
("one row of a million", "SELECT n FROM generate_series(1, 1000000) AS n", 1),
("insert a page", "INSERT INTO docs.pages VALUES ('page-99', 'e-1', 'guides', 100)", 50),
("delete inside WITH", "WITH gone AS (DELETE FROM docs.pages RETURNING slug) SELECT * FROM gone", 50),
("function that writes", "SELECT lo_create(0)", 50),
("read editor emails", "SELECT email FROM docs.editors", 50),
("slow query", "SELECT pg_sleep(2)", 50),
("two statements", "SELECT 1; SELECT 2", 50),
("turn off read-only for the session", "SELECT set_config('default_transaction_read_only', 'off', false)", 50),
("function that writes, next call", "SELECT lo_create(0)", 50),
("one 300,000-character value", "SELECT repeat('x', 300000) AS big", 1),
("send a notification", "SELECT pg_notify('docs', 'hello')", 50),
("too many rows requested", "SELECT slug FROM docs.pages", 1000),
]
def tool_results(dsn):
results = {}
for label, sql, max_rows in QUERIES:
try:
results[label] = {"sql": sql, "max_rows": max_rows, "result": run_readonly_query(dsn, sql, max_rows)}
except QueryRejected as rejected:
results[label] = {"sql": sql, "max_rows": max_rows, "rejected": rejected.reason, "sqlstate": rejected.sqlstate}
return results
def role_checks(dsn):
"""Bypass the tool's limits on purpose: what does the role itself allow?"""
checks = {}
with psycopg.connect(dsn, autocommit=True) as conn:
conn.execute("SET default_transaction_read_only = off") # a session may change its own default
checks["read-only default after SET"] = conn.execute("SHOW default_transaction_read_only").fetchone()[0]
for label, sql in [("insert with read-only off", "INSERT INTO docs.pages VALUES ('page-99', 'e-1', 'guides', 100)"),
("delete with read-only off", "DELETE FROM docs.pages"),
("read editors with read-only off", "SELECT email FROM docs.editors"),
("create a large object with read-only off", "SELECT lo_create(0)")]:
try:
conn.execute(sql)
checks[label] = "allowed"
except psycopg.Error as error:
checks[label] = f"{error.sqlstate}: {error.diag.message_primary}"
return checks
async def mcp_results(dsn):
async with Client(postgres_server.build_server(dsn)) as client:
tools = await client.list_tools()
found = await client.call_tool("run_query", {"sql": "SELECT count(*) AS pages FROM docs.pages"})
denied = await client.call_tool("run_query", {"sql": "SELECT lo_create(0)"})
tool = tools.tools[0].model_dump(by_alias=True, exclude_none=True, mode="json")
return {"tool": tool,
"count": {"isError": found.is_error, "structuredContent": found.structured_content},
"write": {"isError": denied.is_error, "text": denied.content[0].text}}
def record():
with tempfile.TemporaryDirectory() as directory:
with LessonCluster(Path(directory) / "cluster") as cluster:
dsn = cluster.reader_dsn
return {"postgres": cluster.version, "visible_columns": visible_columns(dsn), "write_problems": write_problems(dsn),
"queries": tool_results(dsn), "role_checks": role_checks(dsn), "mcp": asyncio.run(mcp_results(dsn))}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--write", type=Path, help="save the results to this JSON file")
options = parser.parse_args(argv)
results = record()
print(f"PostgreSQL {results['postgres']}")
for label, outcome in results["queries"].items():
if "rejected" in outcome:
shown = f"rejected: {outcome['rejected']}" + (f" ({outcome['sqlstate']})" if outcome["sqlstate"] else "")
else:
shown = f"{len(outcome['result']['rows'])} rows, truncated={outcome['result']['truncated']}"
print(f"{label:36} {shown}")
for label, outcome in results["role_checks"].items():
print(f"role: {label:30} {outcome}")
if options.write:
options.write.write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8")
print(f"wrote {options.write}")
if __name__ == "__main__":
main().venv/bin/python record_results.py --write query-results.jsonHere is the recorded run: 14 queries through the tool, then 5 checks that bypass the tool and test the database role alone.
PostgreSQL 17.4
first five pages 5 rows, truncated=True
pages per section 3 rows, truncated=False
one row of a million 1 rows, truncated=True
insert a page rejected: only one SELECT or VALUES query is allowed (42601)
delete inside WITH rejected: the query uses a feature a cursor does not allow, such as a data-modifying WITH (0A000)
function that writes rejected: the transaction is read-only (25006)
read editor emails rejected: the role lacks the privilege (42501)
slow query rejected: the query ran longer than 500 ms (57014)
two statements rejected: only one SELECT or VALUES query is allowed (42601)
turn off read-only for the session 1 rows, truncated=False
function that writes, next call rejected: the transaction is read-only (25006)
one 300,000-character value rejected: the result is larger than 200000 bytes; select fewer or shorter values
send a notification 1 rows, truncated=False
too many rows requested rejected: max_rows must be an integer from 1 to 200
role: read-only default after SET off
role: insert with read-only off 42501: permission denied for table pages
role: delete with read-only off 42501: permission denied for table pages
role: read editors with read-only off 42501: permission denied for table editors
role: create a large object with read-only off allowed
wrote query-results.jsonRead the recorded output with the layers in mind. one row of a million returned 1 row with truncated true. The insert and the two statements stopped at the cursor with 42601. lo_create(0) is a single SELECT, so the cursor accepted it, and the read-only transaction stopped it with 25006. The editor emails stopped at privileges with 42501, and pg_sleep(2) was cancelled with 57014. A query turned the session’s read-only default off and succeeded, yet lo_create(0) in the next call still failed, because that call used a new connection and its own read-only transaction.
The role: lines bypass the tool. With read-only off, the insert, the delete and the editors read still failed with 42501: table privileges held when every tool limit was gone. Creating a large object was allowed, which is why the tool sets its own read-only transaction.
This excerpt of the saved results shows one query that the row limit cut short and one that the read-only transaction stopped:
{
"one row of a million": {
"sql": "SELECT n FROM generate_series(1, 1000000) AS n",
"max_rows": 1,
"result": {
"columns": [
"n"
],
"rows": [
[
1
]
],
"truncated": true
}
},
"function that writes": {
"sql": "SELECT lo_create(0)",
"max_rows": 50,
"rejected": "the transaction is read-only",
"sqlstate": "25006"
}
}Full output, including the MCP tool definition: query-results.json.
The suite creates one cluster for the module and two more inside tests, and deletes all three:
.venv/bin/python -m unittest -v test_postgres_readonly.pytest_denied_query_is_a_tool_error_with_sqlstate (test_postgres_readonly.McpLayer.test_denied_query_is_a_tool_error_with_sqlstate) ... Tool 'run_query' failed: 'Error executing tool run_query: Query rejected: the transaction is read-only (SQLSTATE 25006).'
ok
test_schema_bounds_max_rows_and_extra_properties_are_ignored (test_postgres_readonly.McpLayer.test_schema_bounds_max_rows_and_extra_properties_are_ignored) ... Tool 'run_query' rejected arguments: ['max_rows']
Tool 'run_query' rejected arguments: ['max_rows']
ok
test_saved_results_match_a_fresh_run (test_postgres_readonly.RecordedResults.test_saved_results_match_a_fresh_run) ... Tool 'run_query' failed: 'Error executing tool run_query: Query rejected: the transaction is read-only (SQLSTATE 25006).'
ok
----------------------------------------------------------------------
Ran 26 tests in 5.668s
OKThe excerpt shows the MCP-layer tests and the summary: all 26 tests passed. The Tool 'run_query' failed lines are the SDK’s log messages for the expected rejections. Full output: test-output.txt.
Read the test source
"""Tests for the read-only PostgreSQL MCP tool. Each run creates and removes its own PostgreSQL 17 cluster.
python -m unittest -v test_postgres_readonly.py
"""
import asyncio
import json
import resource
import socket
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import psycopg
from mcp import Client
import postgres_server
import record_results
from lesson_cluster import LessonCluster
import lesson_cluster
from readonly_query import QueryRejected, check_reader, run_readonly_query, visible_columns, write_problems
HERE = Path(__file__).resolve().parent
CLUSTER = None
def setUpModule():
global CLUSTER, WORKDIR
WORKDIR = tempfile.TemporaryDirectory()
CLUSTER = LessonCluster(Path(WORKDIR.name) / "cluster").create()
def tearDownModule():
CLUSTER.destroy()
WORKDIR.cleanup()
def admin(sql):
with psycopg.connect(CLUSTER.admin_dsn(), autocommit=True) as conn:
conn.execute(sql)
def peak_rss_mb():
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
return peak / 2**20 if sys.platform == "darwin" else peak / 2**10 # bytes on macOS, KiB on Linux
def rejected(sql, max_rows=50):
try:
run_readonly_query(CLUSTER.reader_dsn, sql, max_rows)
except QueryRejected as error:
return error.sqlstate, error.reason
raise AssertionError(f"query was not rejected: {sql}")
class AllowedQueries(unittest.TestCase):
def test_select_returns_columns_and_rows(self):
result = run_readonly_query(CLUSTER.reader_dsn, "SELECT slug, section FROM docs.pages ORDER BY slug", 2)
self.assertEqual(result, {"columns": ["slug", "section"], "rows": [["page-01", "reference"], ["page-02", "changelog"]], "truncated": True})
def test_row_limit_reports_truncation_only_when_rows_were_dropped(self):
self.assertFalse(run_readonly_query(CLUSTER.reader_dsn, "SELECT slug FROM docs.pages", 30)["truncated"])
self.assertTrue(run_readonly_query(CLUSTER.reader_dsn, "SELECT slug FROM docs.pages", 29)["truncated"])
def test_schema_shows_only_granted_table(self):
tables = {column["table"] for column in visible_columns(CLUSTER.reader_dsn)}
self.assertEqual(tables, {"docs.pages"})
class Limits(unittest.TestCase):
def test_insert_is_rejected_by_the_cursor(self):
self.assertEqual(rejected("INSERT INTO docs.pages VALUES ('page-99', 'e-1', 'guides', 100)")[0], "42601")
def test_data_modifying_with_is_rejected(self):
self.assertEqual(rejected("WITH gone AS (DELETE FROM docs.pages RETURNING slug) SELECT * FROM gone")[0], "0A000")
def test_write_is_rejected_by_read_only_transaction(self):
self.assertEqual(rejected("SELECT lo_create(0)")[0], "25006")
def test_unprivileged_table_is_rejected(self):
self.assertEqual(rejected("SELECT email FROM docs.editors")[0], "42501")
def test_slow_query_is_cancelled_by_statement_timeout(self):
self.assertEqual(rejected("SELECT pg_sleep(2)")[0], "57014")
def test_multiple_statements_are_rejected(self):
self.assertEqual(rejected("SELECT 1; SELECT 2")[0], "42601")
def test_session_setting_does_not_carry_into_the_next_call(self):
run_readonly_query(CLUSTER.reader_dsn, "SELECT set_config('default_transaction_read_only', 'off', false)")
self.assertEqual(rejected("SELECT lo_create(0)")[0], "25006")
def test_bad_max_rows_and_empty_sql_are_rejected_before_the_database(self):
for max_rows in (0, 201, True, "5"):
with self.subTest(max_rows=max_rows):
self.assertEqual(rejected("SELECT 1", max_rows), (None, "max_rows must be an integer from 1 to 200"))
self.assertEqual(rejected(" "), (None, "the query is empty"))
def test_large_result_stays_on_the_server(self):
before = peak_rss_mb()
result = run_readonly_query(CLUSTER.reader_dsn, "SELECT n FROM generate_series(1, 3000000) AS n", 1)
self.assertEqual((result["rows"], result["truncated"]), ([[1]], True))
self.assertLess(peak_rss_mb() - before, 64) # buffering 3,000,000 rows in the client would add hundreds of MB
def test_result_byte_budget_rejects_large_values_and_many_wide_rows(self):
reason = "the result is larger than 200000 bytes; select fewer or shorter values"
self.assertEqual(rejected("SELECT repeat('x', 300000)", 1), (None, reason))
self.assertEqual(rejected("SELECT repeat('x', 2000) FROM generate_series(1, 200)", 200), (None, reason))
self.assertEqual(len(run_readonly_query(CLUSTER.reader_dsn, "SELECT repeat('x', 900) FROM generate_series(1, 200)", 200)["rows"]), 200)
def test_notification_is_not_a_write_and_is_allowed(self):
# A gap to know about: pg_notify signals listening sessions even in a read-only transaction.
self.assertEqual(run_readonly_query(CLUSTER.reader_dsn, "SELECT pg_notify('docs', 'hello')")["rows"], [[""]])
def test_transaction_limits_hold_without_role_defaults(self):
admin("ALTER ROLE mcp_reader IN DATABASE docs_lesson RESET ALL")
try:
self.assertEqual(rejected("SELECT lo_create(0)")[0], "25006") # needs SET TRANSACTION READ ONLY
self.assertEqual(rejected("SELECT pg_sleep(2)")[0], "57014") # needs the per-transaction timeout
finally:
admin("ALTER ROLE mcp_reader IN DATABASE docs_lesson SET default_transaction_read_only = on")
admin("ALTER ROLE mcp_reader IN DATABASE docs_lesson SET statement_timeout = '500ms'")
class RolePrivileges(unittest.TestCase):
def test_role_cannot_write_even_with_read_only_turned_off(self):
with psycopg.connect(CLUSTER.reader_dsn, autocommit=True) as conn:
conn.execute("SET default_transaction_read_only = off")
with self.assertRaises(psycopg.errors.InsufficientPrivilege):
conn.execute("DELETE FROM docs.pages")
def test_table_grants_do_not_cover_large_objects(self):
# A gap the read-only transaction closes: with read-only off, the reader can create a large object.
with psycopg.connect(CLUSTER.reader_dsn) as conn:
conn.execute("SET TRANSACTION READ WRITE") # the first statement of this transaction
oid = conn.execute("SELECT lo_create(0)").fetchone()[0]
conn.rollback()
self.assertIsInstance(oid, int)
def test_server_refuses_a_role_that_can_write(self):
with self.assertRaises(SystemExit):
check_reader(CLUSTER.admin_dsn())
self.assertEqual(write_problems(CLUSTER.reader_dsn), [])
check_reader(CLUSTER.reader_dsn) # the read-only role passes
def test_check_finds_write_grants_the_role_could_inherit(self):
grants = {
"pg_write_all_data": ("GRANT pg_write_all_data TO mcp_reader", "REVOKE pg_write_all_data FROM mcp_reader",
"is a member of pg_write_all_data"),
"owner membership": ("GRANT docs_owner TO mcp_reader", "REVOKE docs_owner FROM mcp_reader",
"can change rows or columns of a table"),
"column UPDATE": ("GRANT UPDATE (section) ON docs.pages TO mcp_reader", "REVOKE UPDATE (section) ON docs.pages FROM mcp_reader",
"can change rows or columns of a table"),
"membership without inherit": ("GRANT docs_owner TO mcp_reader WITH INHERIT FALSE", "REVOKE docs_owner FROM mcp_reader",
"is a member of another role, which SET ROLE could switch to"),
}
for label, (grant, revoke, problem) in grants.items():
with self.subTest(label):
admin(grant)
try:
self.assertIn(problem, write_problems(CLUSTER.reader_dsn))
with self.assertRaises(SystemExit):
check_reader(CLUSTER.reader_dsn)
finally:
admin(revoke)
self.assertEqual(write_problems(CLUSTER.reader_dsn), [])
def test_reader_cannot_connect_to_other_databases(self):
with self.assertRaises(psycopg.OperationalError):
psycopg.connect(CLUSTER.reader_dsn.replace("dbname=docs_lesson", "dbname=postgres"), connect_timeout=2)
def test_destroy_refuses_directories_that_are_not_lesson_clusters(self):
with tempfile.TemporaryDirectory() as directory:
keep = Path(directory) / "notes.txt"
keep.write_text("not a cluster")
with self.assertRaises(SystemExit):
LessonCluster(directory).destroy()
self.assertTrue(keep.exists())
for unsafe in ("", " ", ".", str(Path.home())):
with self.subTest(path=unsafe), self.assertRaises(SystemExit):
LessonCluster(unsafe)
def test_failed_create_leaves_no_running_server(self):
with tempfile.TemporaryDirectory() as directory:
cluster = LessonCluster(Path(directory) / "broken")
with mock.patch.object(LessonCluster, "seed", side_effect=RuntimeError("seed failed")):
with self.assertRaises(RuntimeError):
cluster.create()
self.assertFalse((Path(directory) / "broken").exists())
with socket.socket() as probe:
self.assertNotEqual(probe.connect_ex(("127.0.0.1", cluster.port)), 0)
def test_destroy_stops_the_cluster_and_removes_its_files(self):
with tempfile.TemporaryDirectory() as directory:
cluster = LessonCluster(Path(directory) / "second").create()
dsn = cluster.reader_dsn
cluster.destroy()
self.assertFalse((Path(directory) / "second").exists())
with self.assertRaises(psycopg.OperationalError):
psycopg.connect(dsn, connect_timeout=2)
class McpLayer(unittest.TestCase):
def call(self, arguments):
async def run():
async with Client(postgres_server.build_server(CLUSTER.reader_dsn)) as client:
return await client.call_tool("run_query", arguments)
return asyncio.run(run())
def test_denied_query_is_a_tool_error_with_sqlstate(self):
result = self.call({"sql": "SELECT lo_create(0)"})
self.assertTrue(result.is_error)
self.assertIn("Query rejected: the transaction is read-only (SQLSTATE 25006).", result.content[0].text)
def test_schema_bounds_max_rows_and_extra_properties_are_ignored(self):
self.assertTrue(self.call({"sql": "SELECT 1", "max_rows": "5"}).is_error) # strict integer
self.assertTrue(self.call({"sql": "SELECT 1", "max_rows": 201}).is_error) # maximum 200
ignored = self.call({"sql": "SELECT 1", "extra": "value"}) # mcp 2.3.0 drops unknown keys
self.assertEqual((ignored.is_error, ignored.structured_content["rows"]), (False, [[1]]))
class RecordedResults(unittest.TestCase):
def test_saved_results_match_a_fresh_run(self):
self.assertEqual(json.loads((HERE / "query-results.json").read_text()), record_results.record())
if __name__ == "__main__":
unittest.main()Enforce query limits inside PostgreSQL
The model sees one tool. These are the recorded inputSchema, a successful call and a refused one, as the MCP client received them:
{
"inputSchema": {
"type": "object",
"properties": {
"sql": {
"maxLength": 10000,
"minLength": 1,
"title": "Sql",
"type": "string"
},
"max_rows": {
"default": 50,
"maximum": 200,
"minimum": 1,
"title": "Max Rows",
"type": "integer"
}
},
"required": [
"sql"
],
"title": "run_queryArguments"
},
"run_query count(*)": {
"isError": false,
"structuredContent": {
"columns": [
"pages"
],
"rows": [
[
30
]
],
"truncated": false
}
},
"run_query lo_create(0)": {
"isError": true,
"text": "Error executing tool run_query: Query rejected: the transaction is read-only (SQLSTATE 25006)."
}
}For each call, the SDK validates the arguments against the schema, then run_readonly_query() opens a connection as mcp_reader and sends, in one transaction: SET TRANSACTION READ ONLY, the set_config for the timeout, DECLARE run_query CURSOR FOR the model’s SQL, and one FETCH for at most max_rows + 1 rows. PostgreSQL parses the SQL as part of DECLARE, so a second statement or an INSERT is a syntax error there. It checks privileges and the read-only rule as it plans and runs the query, and the statement timeout covers the FETCH, where the work happens. A psycopg error exits the transaction and connection context, rolling back and closing the connection. The wrapper sends the SQLSTATE with a fixed reason rather than the server’s error text. Argument and byte-budget rejections have no SQLSTATE.
Where the injection boundary is
The usual defense against SQL injection, passing values as parameters, does not apply: the model writes the whole statement, so the SQL is the input. Filtering it in Python, for example by rejecting strings that contain DELETE, is easy to get around: lo_create(0) writes without any write keyword, and a WITH clause can hide a DELETE inside a SELECT. The boundary has to be the database itself: a role that cannot write and cannot read what it should not, inside a transaction that cannot write. The tool’s own checks narrow the shape of a query and bound its cost, and the role decides what it can touch.
The design choices
- A new connection per call, or a pool. A connection per call costs a TCP connection and a password exchange on every query, and in return no
SETorset_configfrom one call can reach the next. A pool saves that latency, but each connection must then be reset, for example withDISCARD ALL, before reuse, and a missed reset carries one call’s settings into another. - Timeout per transaction, or on the role. The role default is one
ALTER ROLE, but a session can override it withSET.set_config(..., true)in every transaction cannot be skipped by the model’s query, because it runs before that query. The test that resets the role defaults shows the tool’s own limits still holding. - A row cap, or a byte cap. Rows are what the model asks for; bytes are what fills its context. The tool enforces both, because one row can hold a 300,000-character value.
Check your understanding
Read the explanation
A SELECT can still write through a function. The cursor limits the shape of the query, and the read-only transaction refuses the write; table privileges alone would not have stopped it.
Return to the MCP course to review the server, client and access boundaries together.
References
Checked October 21, 2026, against PostgreSQL 17, psycopg 3.3, MCP specification 2026-07-28 and mcp 2.3.0.
- PostgreSQL 17: privileges and schemas and privileges
- PostgreSQL 17: ALTER ROLE
- PostgreSQL 17: SET TRANSACTION
- PostgreSQL 17: client connection defaults, statement_timeout
- PostgreSQL 17: error codes
- PostgreSQL 17: DECLARE and access privilege inquiry functions
- PostgreSQL 17: information_schema.columns
- psycopg 3: client-side and server-side cursors
- MCP specification: tools
- MCP Python SDK repository and mcp 2.3.0 on PyPI