adaptive-pg-mcp (PostgreSQL)
Provides typed MCP tools for interacting with PostgreSQL databases: running reviewed SQL with typed parameters, executing free SQL under the calling login's grants, reading data in read-only transactions, enforcing row/byte limits, rolling back oversized writes, and applying PostgreSQL permissions, row-level security, and column privileges.
adaptive-pg-mcp
An MCP server for PostgreSQL. Agents get typed tools on named databases, an organization's reviewed SQL as named tools with typed parameters, and free SQL that runs under the grants of the login each call uses. It is one static Go binary that serves Streamable HTTP and stdio to any MCP client. Behind the Straza gateway, each agent gets only the tools and databases its policy allows, with approval holds and an audit record per call, and the server needs no change for it.
Postgres 14 to 18 are supported. The server targets MCP revision 2026-07-28 and also serves the earlier revisions that the official Go SDK serves.
From release 0.1.0 on, a change to server.yaml, access.yaml or tools.yaml only adds to the format, and CHANGELOG.md records each one. The design is docs/DESIGN.md.
What the server enforces
On every call:
A call names one database from server.yaml and runs on a login of that database.
Each statement goes to Postgres as one unnamed prepared statement on the extended protocol, and each call runs in one transaction, with
statement_timeoutset from the configured caps. A text that holds two statements is refused.Read tools and
query_sqlrun in aREAD ONLYtransaction. A read stops at the row cap or the byte cap and returnstruncated: truewhen more rows follow.A write that changes more rows than
write_rowsis rolled back. The count includes rows changed by triggers, cascades, functions andWITHqueries. Withtrack_countsoff, every write is refused, because the count cannot be read.execute_sqlandexecute_ddltell row changes from structure changes by what Postgres reports: the command tag and the rows changed in the system catalogs. The server never parses the SQL text, so aCREATE TABLEinside aDOblock is rolled back inexecute_sqllike a plain one.Free SQL and catalog tools close their connection after each call, so session state such as a
SETor an advisory lock never reaches the next call.The table tools quote every identifier and send every value as a bind parameter.
A cancelled call cancels its statement in Postgres.
On every request:
Over HTTP, the server compares the SHA-256 of each request's bearer token with the configured hashes, in constant time. A protocol session id is never taken as proof of who calls.
A request with an
Originheader is refused, because the server serves no web page. A request body over 1 MiB is refused.In standalone mode, access.yaml decides which tools, databases and tables each caller may use, and
tools/listshows each caller only its own tools.Without
tls, HTTP listens on a loopback address only, unlessallow_plain_http: truesays that something in front encrypts the traffic.No token or connection string appears in a log line or an error. The caller's token is never sent to Postgres or anywhere else.
Before it serves, the start checks refuse a login that is a superuser while free SQL is on, a database with dblink or postgres_fdw installed, logins of one database that reach different databases, and a catalog statement that does not prepare or whose login lacks a grant it needs.
What it leaves to Postgres:
The grants of each login decide what it may read and change. Free SQL runs with every grant the login holds on its database. A
READ ONLYtransaction stops writes to tables, but a function the login may execute still runs. For a hard boundary, give reads a login withSELECTonly, and give the write login only the grants its writes need.Row-level security and column privileges apply to every call, because every statement runs as the login. The server does not filter rows per person.
Related MCP server: postgres-mcp
Quick start
The compose example starts Postgres 18 with a helpdesk database, a read
login and a write login, and the server in front of it, built from this
repository.
git clone https://github.com/strazahq/adaptive-pg-mcp
cd adaptive-pg-mcp/examples/compose
docker compose up --buildThe server listens on http://127.0.0.1:8080/mcp and accepts the bearer
token demo-token. Add it to Claude Code:
claude mcp add --transport http helpdesk-demo http://127.0.0.1:8080/mcp \
--header "Authorization: Bearer demo-token"Or check that it responds:
token=demo-token
curl -s http://127.0.0.1:8080/mcp \
-H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'The demo serves every tool, with free_sql: read-write, and the two catalog
tools of examples/catalog. It serves plain HTTP inside
the compose network and publishes the port on 127.0.0.1 only. The passwords
and the token are for the demo only. docker compose down -v removes the
containers and the data.
Install
Container image
docker pull ghcr.io/strazahq/adaptive-pg-mcp:0.1.1The image holds the static binary on distroless static and runs as the
nonroot user, uid 65532. Its default command is
serve -config /etc/adaptive-pg-mcp/server.yaml. It is built for
linux/amd64 and linux/arm64. A release tag vX.Y.Z publishes X.Y.Z, and
each push to main publishes main and sha-<commit>. Each image carries a
signed build provenance attestation, and from 0.1.1 on an SPDX SBOM that
scanners such as Trivy, Grype and Docker Scout read from the registry:
gh attestation verify oci://ghcr.io/strazahq/adaptive-pg-mcp:0.1.1 --owner strazahq
docker buildx imagetools inspect ghcr.io/strazahq/adaptive-pg-mcp:0.1.1 --format '{{ json .SBOM }}'Release binaries
Each release holds a
static binary for Linux, macOS and Windows on amd64 and arm64, with a
SHA256SUMS file and a signed build provenance attestation:
curl -fsSLO https://github.com/strazahq/adaptive-pg-mcp/releases/download/v0.1.1/adaptive-pg-mcp_0.1.1_linux_amd64.tar.gz
curl -fsSLO https://github.com/strazahq/adaptive-pg-mcp/releases/download/v0.1.1/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
gh attestation verify adaptive-pg-mcp_0.1.1_linux_amd64.tar.gz --owner strazahq
tar -xzf adaptive-pg-mcp_0.1.1_linux_amd64.tar.gzFrom 0.1.1 on, each archive has an SPDX SBOM of its binary beside it, such
as adaptive-pg-mcp_0.1.1_linux_amd64.spdx.json. SHA256SUMS lists it, and
a signed attestation binds it to its archive:
gh attestation verify adaptive-pg-mcp_0.1.1_linux_amd64.tar.gz --owner strazahq \
--predicate-type https://spdx.dev/Document/v2.3The Windows archives are .zip files. macOS marks a downloaded binary as
quarantined, and xattr -d com.apple.quarantine adaptive-pg-mcp lifts that.
go install
GOTOOLCHAIN=go1.27.3 go install github.com/strazahq/adaptive-pg-mcp/cmd/adaptive-pg-mcp@latestgo install builds with the Go you run and ignores the toolchain that
go.mod pins. Earlier releases, such as Go 1.26.5, carry standard library
vulnerabilities that govulncheck finds on the server's code paths, so set
GOTOOLCHAIN as above, or run Go 1.27.3 or newer.
From source
git clone https://github.com/strazahq/adaptive-pg-mcp
cd adaptive-pg-mcp
CGO_ENABLED=0 go build -trimpath -o adaptive-pg-mcp ./cmd/adaptive-pg-mcpInside the module, the go command fetches the toolchain that go.mod pins
when the installed Go is older. docker build -t adaptive-pg-mcp . builds
the image.
Connect a client
Over Streamable HTTP, the endpoint is /mcp, and each request sends
Authorization: Bearer <token>. Over stdio, the client starts
adaptive-pg-mcp serve itself, with a server.yaml whose listen is
stdio, the token in ADAPTIVE_PG_MCP_TOKEN and each connection string in
the variable that server.yaml names. serve checks the token once at start
and does not start without a match.
A server.yaml for stdio:
version: 1
mode: standalone
listen: stdio
free_sql: read
limits: { rows: 1000, bytes: 1048576, timeout: 30s, write_rows: 100 }
access: ./access.yaml
catalog: none
databases:
helpdesk:
logins:
read: { dsn_env: HELPDESK_READ_DSN }
write: { dsn_env: HELPDESK_WRITE_DSN }The examples below use https://mcp.example.internal:8443/mcp for HTTP and
/etc/adaptive-pg-mcp/stdio.yaml for stdio. The client machine must trust
the CA that signed the server's certificate.
HTTP:
claude mcp add --transport http adaptive-pg-mcp https://mcp.example.internal:8443/mcp \
--header "Authorization: Bearer $ADAPTIVE_PG_MCP_TOKEN"stdio:
claude mcp add --env ADAPTIVE_PG_MCP_TOKEN="$ADAPTIVE_PG_MCP_TOKEN" \
--env HELPDESK_READ_DSN="$HELPDESK_READ_DSN" \
--env HELPDESK_WRITE_DSN="$HELPDESK_WRITE_DSN" \
--transport stdio adaptive-pg-mcp \
-- adaptive-pg-mcp serve -config /etc/adaptive-pg-mcp/stdio.yamlBoth commands write the values into the Claude Code config. A project
.mcp.json can read them from the environment instead:
{
"mcpServers": {
"adaptive-pg-mcp": {
"type": "http",
"url": "https://mcp.example.internal:8443/mcp",
"headers": {
"Authorization": "Bearer ${ADAPTIVE_PG_MCP_TOKEN}"
}
}
}
}Claude Desktop connects to remote servers from Anthropic's cloud, which
cannot reach a server on your machine or a private network. Run the server
over stdio from claude_desktop_config.json, which is
~/Library/Application Support/Claude/claude_desktop_config.json on macOS
and %APPDATA%\Claude\claude_desktop_config.json on Windows:
{
"mcpServers": {
"adaptive-pg-mcp": {
"command": "/usr/local/bin/adaptive-pg-mcp",
"args": ["serve", "-config", "/etc/adaptive-pg-mcp/stdio.yaml"],
"env": {
"ADAPTIVE_PG_MCP_TOKEN": "apm_...",
"HELPDESK_READ_DSN": "postgres://helpdesk_read:...@pg.example.internal:5432/helpdesk?sslmode=verify-full",
"HELPDESK_WRITE_DSN": "postgres://helpdesk_write:...@pg.example.internal:5432/helpdesk?sslmode=verify-full"
}
}
}
}The file then holds the token and the passwords, so keep it readable by you alone. Restart Claude Desktop after a change.
Put this in .vscode/mcp.json, or in the user configuration that
MCP: Open User Configuration opens. VS Code prompts for the token when
the server first starts.
{
"inputs": [
{
"type": "promptString",
"id": "adaptive-pg-mcp-token",
"description": "adaptive-pg-mcp token",
"password": true
}
],
"servers": {
"adaptive-pg-mcp": {
"type": "http",
"url": "https://mcp.example.internal:8443/mcp",
"headers": {
"Authorization": "Bearer ${input:adaptive-pg-mcp-token}"
}
},
"adaptive-pg-mcp-stdio": {
"type": "stdio",
"command": "adaptive-pg-mcp",
"args": ["serve", "-config", "/etc/adaptive-pg-mcp/stdio.yaml"],
"env": {
"ADAPTIVE_PG_MCP_TOKEN": "${input:adaptive-pg-mcp-token}"
},
"envFile": "/etc/adaptive-pg-mcp/dsn.env"
}
}
}envFile names a file of NAME=value lines that holds the connection
strings.
Put this in .cursor/mcp.json of the project, or in ~/.cursor/mcp.json
for every project. ${env:NAME} reads a variable from the environment Cursor runs in.
{
"mcpServers": {
"adaptive-pg-mcp": {
"url": "https://mcp.example.internal:8443/mcp",
"headers": {
"Authorization": "Bearer ${env:ADAPTIVE_PG_MCP_TOKEN}"
}
},
"adaptive-pg-mcp-stdio": {
"type": "stdio",
"command": "adaptive-pg-mcp",
"args": ["serve", "-config", "/etc/adaptive-pg-mcp/stdio.yaml"],
"env": {
"ADAPTIVE_PG_MCP_TOKEN": "${env:ADAPTIVE_PG_MCP_TOKEN}",
"HELPDESK_READ_DSN": "${env:HELPDESK_READ_DSN}",
"HELPDESK_WRITE_DSN": "${env:HELPDESK_WRITE_DSN}"
}
}
}
}Configuration
The server reads server.yaml, access.yaml in standalone mode, and the
catalog. Each YAML file has version: 1. An unknown key, a missing required
key, a value the server cannot use or a second YAML document fails the load
with a sentence that names the file and what to fix.
docs/DESIGN.md section 3 holds every rule, and
examples/ holds files that the tests load.
server.yaml
version: 1
mode: standalone
listen: 127.0.0.1:8080
free_sql: read
limits: { rows: 1000, bytes: 1048576, timeout: 30s, write_rows: 100 }
access: ./access.yaml
catalog: ./catalog
databases:
sales_eu:
login: { dsn_env: SALES_EU_DSN }
helpdesk:
logins:
read: { dsn_env: HELPDESK_READ_DSN }
write: { dsn_env: HELPDESK_WRITE_DSN }Key | Required | What it sets |
| yes | The format version, 1. |
| yes |
|
| yes |
|
| no |
|
| no |
|
| yes |
|
| yes |
|
| standalone mode | The path of access.yaml. Refused in straza mode. |
| straza mode | The SHA-256 of each token Straza calls with. Refused in standalone mode. |
| no | The SHA-256 of each token Straza sends only to |
| yes | The catalog folder, or |
| yes | Each database under the name that calls use, with its logins. |
Relative paths resolve against the folder of server.yaml. serve and
check read ./server.yaml unless -config names another file.
A database has one login, or logins with read, write and any extra
login that a catalog tool names, such as finance_reader. Each login names
the environment variable that holds its connection string in dsn_env, so
no password sits in the file. A connection string takes either libpq form,
a URL or key=value pairs, such as
postgres://helpdesk_read@pg.example.internal:5432/helpdesk?sslmode=verify-full.
Logins are real logins, never roles to switch to, because free SQL can
switch roles. One login for reads and writes works: reads still run in
read-only transactions. Separate logins add the database's own grants as a
second boundary.
Two keys per database relax a start check:
allow_cross_database: trueaccepts dblink or postgres_fdw in the database.allow_superuser: trueaccepts a superuser login while free SQL is on.
Postgres logins
The server runs on logins that a DBA creates for it, and it creates none
itself. A read login and a write login for the helpdesk database of the
examples:
CREATE ROLE helpdesk_read LOGIN PASSWORD '...';
CREATE ROLE helpdesk_write LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE helpdesk TO helpdesk_read, helpdesk_write;
GRANT USAGE ON SCHEMA public TO helpdesk_read, helpdesk_write;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO helpdesk_read;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO helpdesk_write;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO helpdesk_write;Grants decide what agents see as well as what they change. The listing tools and
describe_tableshow only the schemas, tables and columns that the login may use.The write login needs
SELECTnext to the change grants, because Postgres checks it for the columns that awherefilter orreturningreads.A column whose default calls
nextval, such as aserialcolumn, needsUSAGEon its sequence.GRANT ... ON ALL TABLEScovers the tables that exist now. UseALTER DEFAULT PRIVILEGESfor tables created later.execute_ddlruns on the write login, so it changes only what that login owns or may create. GrantCREATEon a schema only where agents should create tables.Never give the server a superuser login while free SQL is on. The start refuses one, and reports a login that holds
BYPASSRLSor may write to every table.
access.yaml
In standalone mode, access.yaml lists the callers:
version: 1
callers:
- name: support_bot
token_sha256: "<64 lowercase hex characters>"
tools: [list_databases, list_tables, describe_table, read_rows, insert_rows]
databases: [helpdesk]
tables: [public.tickets] # optionaltools and databases name what the caller may use. tables narrows the
table tools to those tables, and a caller with tables may not use free
SQL, because free SQL can reach any table. A catalog tool is listed in
tools by its name.
adaptive-pg-mcp token prints a new token and its hash:
token: apm_...
token_sha256: 3f0c...The token goes to the caller, and the hash goes into token_sha256. The
server keeps neither, so the token shows only once.
The catalog
The catalog turns reviewed SQL into tools. It is a folder of .sql files,
with one tools.yaml index per folder, at any depth. A statement starts at
a -- name: line:
-- name: close_stale_tickets
UPDATE public.tickets
SET status = 'closed'
WHERE status = 'pending'
AND updated_on < current_date - $1::int4
RETURNING id;The index of its folder makes it a tool:
version: 1
tools:
close_stale_tickets:
description: Closes the tickets that have waited in pending for more than a number of days. Returns the id of each ticket it closed.
mode: write # or read
databases: [helpdesk]
login: write # read, write, or an extra login
params: # in $1 order
- name: days
description: How many days a ticket waits in pending before it closes, from 7 to 90.
required: true # also enum, pattern, minimum, maximum, maxLength
minimum: 7
maximum: 90
limits: { write_rows: 50 } # optional: rows, bytes, timeout, write_rowsPostgres reports the type of each parameter when the server starts, and the
tool's input schema follows it. A parameter may be of type bool, int2, int4,
int8, float4, float8, numeric, text, varchar, bpchar, uuid, date, timestamp,
timestamptz, json or jsonb, or of a domain over one of them. A parameter
that a call leaves out binds NULL. A read tool runs in a read-only
transaction, and a write tool is rolled back when it changes more rows than
its cap or changes structure. Each field of an entry's limits replaces
the same field of server.yaml for that tool, and the start reports each cap
an entry raises.
The catalog skips files and folders whose name starts with a dot, so it loads from a Kubernetes ConfigMap mount. It follows a link only when the link is relative and stays inside the catalog folder.
Writing catalog tools
Agents never read this README. When a client connects, it fetches the tool
list, and the agent picks a tool by its name, its description, the
description, type and limits of each parameter, and whether it reads or
writes. The description in tools.yaml is the agent's documentation, so
say what the tool does, what it returns and its caps, in words an agent can
act on. The start refuses a tool or a parameter without a description.
Each tool is exactly one SQL statement, because the server prepares it as
one statement. A script of several statements, such as one wrapped in
BEGIN and COMMIT, or a psql command such as \copy does not prepare,
and the start refuses it. Parameters are $1, $2 and so on, in the order
of params. psql variables such as :month do not work. A parameter of a
type outside the list above needs a cast in the statement, such as
$1::text.
One .sql file may hold many statements, each from its -- name: line to
the next one. A statement that no tools.yaml lists is not a tool, and the
start reports it. A tools.yaml entry without a statement of its name
refuses the start.
The server reads the catalog once, at start, so a changed file takes effect
after a restart. adaptive-pg-mcp check prepares every statement on its
databases and runs the grants check without serving, so CI can run it
against a staging database before a deploy.
Running a catalog in production
A catalog fits in a git repository of its own, next to access.yaml:
access.yaml
catalog/
helpdesk/
tickets.sql
tools.yaml
sales/
revenue.sql
tools.yamlEvery change is a pull request, and a code owners rule makes a DBA review each statement and its description.
CI runs
adaptive-pg-mcp checkagainst a staging database with the same schema, so a statement that does not prepare or lacks a grant fails the pull request.A merge deploys the files, and the server loads them at its next start.
examples/kubernetes makes one ConfigMap per catalog folder, because a ConfigMap key holds no folder. Kustomize puts a hash of each ConfigMap's content in its name, so a changed file rolls the Deployment. A ConfigMap holds at most 1 MiB, so a large catalog does not fit in one. Build an image of your own on this one instead, which also versions the SQL with the image:
FROM ghcr.io/strazahq/adaptive-pg-mcp:0.1.1
COPY catalog /usr/share/adaptive-pg-mcp/catalogThen set catalog: /usr/share/adaptive-pg-mcp/catalog in server.yaml. The
path stays outside /etc/adaptive-pg-mcp, so the mount that brings
server.yaml and access.yaml does not hide it.
Tools
Every tool except list_databases takes a required database argument,
whose schema lists the databases the caller may use. Where a tool takes a
table, schema defaults to public. Results carry structuredContent that
matches the tool's output schema, and a short text. A bad argument or a
refusal returns a tool result with isError: true and a sentence that says
what to change.
Tool | What it does | Login | Kind |
| Lists the configured databases with their ids, 25 per page. | read | read |
| Lists the schemas the login may use, up to 100 per page. | read | read |
| Lists the tables, views and foreign tables the login may use, in one schema or all, up to 100 per page. | read | read |
| Returns the columns of a table or view: name, type, nullable, default and primary key. | read | read |
| Returns rows, with a column list, | read | read |
| Returns | read | read |
| Inserts rows and returns | write | write |
| Updates the rows a required | write | write |
| Deletes the rows a required | write | write |
| Runs one SQL statement in a read-only transaction. Needs | read | read |
| Runs one statement that changes rows, such as | write | write |
| Runs | write | write |
one per catalog statement | Runs the statement with the call's arguments as its parameters. | its entry's | its entry's |
A where filter is a list of {column, op, value}, with op one of =,
<>, <, <=, >, >=, in, not in, is null, is not null,
like and ilike. A read returns columns with their Postgres types,
rows as arrays in column order, and truncated. Numbers and booleans are
JSON numbers and booleans, json is JSON, dates and timestamps are ISO 8601
with timestamptz in UTC, and other types are their Postgres text.
Read tools set readOnlyHint, and write tools set destructiveHint.
Start checks
serve runs the start checks before it serves, and check runs them
without serving. They connect every login, and these findings refuse the
start:
A login is a superuser, or a member of a superuser role, while free SQL is on, and its database does not set
allow_superuser: true.A database that one of its logins reaches has dblink or postgres_fdw installed, and the database does not set
allow_cross_database: true.A login of a database reaches another database than its read login, compared by the cluster's system identifier and the database OID, or the two cannot be compared.
A catalog statement does not prepare on one of its databases, takes a different number of parameters than its index lists, needs a grant that its login lacks, fails to plan for a reason that its NULL parameters cannot cause, or has a parameter whose type differs between two of its databases.
A login cannot connect, the catalog does not load, or the TLS certificate and key do not load or the certificate has expired.
The grant check prepares each catalog statement and plans it with
EXPLAIN (ANALYZE false) and NULL for each parameter, on its entry's login
in a read-only transaction that is rolled back. It runs no statement. A few
grants show only on the first call, such as USAGE on a sequence that a
column default calls.
Everything else is reported: what each login can do, such as writing to
every table or holding BYPASSRLS, a statement whose grants EXPLAIN cannot
check, each cap a catalog entry raises, and each tool in access.yaml that
the server does not serve.
$ adaptive-pg-mcp check -config /etc/adaptive-pg-mcp/server.yaml
report: ...
check: no refusals, so adaptive-pg-mcp would start.check prints one refusal: or report: line per finding and a closing
check: line, and exits 1 when serve would refuse to start. It needs the
same connection strings as serve, so a catalog repository can run it in
its CI against the real databases. serve logs refusals at ERROR and
reports at WARN, on stderr.
TLS
With tls, Streamable HTTP serves HTTPS only:
listen: 0.0.0.0:8443
tls:
cert_file: /etc/adaptive-pg-mcp/tls/tls.crt # the certificate, then any intermediates
key_file: /etc/adaptive-pg-mcp/tls/tls.keyIt accepts TLS 1.2 and later with Go's default cipher suites, offers HTTP/2 and HTTP/1.1, and asks for no client certificate. A pair that does not load, or a certificate that has expired, refuses the start. While it serves, the server reads both files again at a handshake once a minute has passed since the last read. A tool such as cert-manager that replaces them renews the pair without a restart, and a new pair that does not load leaves the old one in service with a warning.
Without tls, listen must be localhost or a loopback address. Any other
address, 0.0.0.0 included, fails the load unless allow_plain_http: true
says that something in front of the server encrypts the traffic, such as a
service mesh. The start then reports that bearer tokens cross the network
in cleartext.
Behind the Straza gateway
In straza mode the server trusts the gateway for identity and policy:
mode: straza
gateway_tokens_sha256:
- "<SHA-256 of the token Straza calls with>"
listing_tokens_sha256:
- "<SHA-256 of the token Straza lists with>"The server accepts only those tokens. A gateway token reaches every tool,
and every request with it has the caller straza. A listing token sees and
calls list_databases only, so a leaked one cannot read or change data. A
hash in both lists fails the load, and a listing token whose hash is in
gateway_tokens_sha256 alone would reach every tool, so keep it in
listing_tokens_sha256. Straza decides which tools and which databases each agent may
use, holds a call for approval when its policy says so, and writes the
audit record. Each data tool carries the mark
ai.straza/object: {type: database, argument: database}, and
list_databases lists the databases for Straza with an id that joins the
cluster's system identifier, the database OID and the config name. When a
call's _meta carries ai.straza/object-id, the server compares it with
the id read on the call's own connection and refuses a mismatch, so a call
on a database dropped and created again under the same name is refused
until Straza lists the databases again.
Docker and Kubernetes
The image reads /etc/adaptive-pg-mcp/server.yaml. Mount a folder that
holds server.yaml, access.yaml and the catalog there, and pass each
connection string as an environment variable. Inside a container, listen
needs an address that the host can reach, such as 0.0.0.0:8443, and with
it tls or allow_plain_http: true.
docker run --rm -p 127.0.0.1:8443:8443 \
-v /srv/adaptive-pg-mcp:/etc/adaptive-pg-mcp:ro \
-e HELPDESK_READ_DSN -e HELPDESK_WRITE_DSN \
ghcr.io/strazahq/adaptive-pg-mcp:0.1.1The same image makes tokens, runs the checks, and serves stdio for a client that starts it:
docker run --rm ghcr.io/strazahq/adaptive-pg-mcp:0.1.1 token
docker run --rm -v /srv/adaptive-pg-mcp:/etc/adaptive-pg-mcp:ro \
-e HELPDESK_READ_DSN -e HELPDESK_WRITE_DSN \
ghcr.io/strazahq/adaptive-pg-mcp:0.1.1 check -config /etc/adaptive-pg-mcp/server.yaml
docker run -i --rm -v /srv/adaptive-pg-mcp:/etc/adaptive-pg-mcp:ro \
-e ADAPTIVE_PG_MCP_TOKEN -e HELPDESK_READ_DSN -e HELPDESK_WRITE_DSN \
ghcr.io/strazahq/adaptive-pg-mcp:0.1.1 serve -config /etc/adaptive-pg-mcp/stdio.yamlexamples/kubernetes holds manifests for
kubectl apply -k. They need cert-manager and an issuer.
The Deployment runs one replica as uid 65532, with a read-only root file system, no privilege escalation, every capability dropped and the RuntimeDefault seccomp profile, which the restricted Pod Security Standard of its namespace enforces.
server.yaml and access.yaml, and each catalog folder, become ConfigMaps whose names carry a hash of their content, so a change rolls the Deployment. The server reads them only at start.
A projected volume puts the ConfigMaps and the cert-manager Secret into one folder at the paths server.yaml names. cert-manager renews the pair in place, and the server serves the new pair without a restart.
The connection strings come from a Secret through the environment. The Secret is created outside the manifests, as kustomization.yaml shows.
The probes open a TCP connection, because every HTTP request needs a token.
Clients on revisions before 2026-07-28 keep a protocol session in the memory of one replica, so more replicas need session affinity in front.
A test loads the example's server.yaml, access.yaml and catalog, and checks that the manifests deliver each connection string and catalog file.
Development
make hooks # bind the git hooks in .githooks, once per clone
make check # text, state and Go gates, gofmt, go vet and go test
make pgtest # the database tests on Postgres 14 to 18 in local Docker
make security # gitleaks, govulncheck, osv-scanner and the spec gatego test ./... needs no database. Tests that touch one carry the build tag
pg, and make pgtest runs them against one container per major version,
which tools/pg.sh starts and removes. The push gate runs the security
scans and the database tests. The workflow in
.github/workflows/image.yml runs go vet,
go test and govulncheck, and then builds and publishes the image.
docs/DESIGN.md is the design, docs/PLAN.md holds the decisions and invariants, and CHANGELOG.md records each change.
License
Available Tools
12 toolsaggregateARead-onlyIdempotent
Computes count, sum, avg, min or max over one table or view, with an optional filter and grouping. Returns at most 1,000 groups.
| Name | Required | Description | Default |
|---|---|---|---|
| table | Yes | The table or view to aggregate, by exact name. | |
| where | No | Conditions joined by AND. Omit to aggregate every row. | |
| column | No | The column to aggregate. Required except for count, which counts rows without it. | |
| schema | No | The schema, by exact name. Defaults to public. | |
| database | Yes | The database to run on. | |
| function | Yes | The aggregate function. | |
| group_by | No | Columns to group and order by. Omit for one row. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | The result rows, each an array in column order. |
| columns | Yes | The result columns, in order. |
| truncated | Yes | True when the row cap, the byte cap or limit cut off more rows. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnly, idempotent, non-destructive, closed-world behavior, so the safety profile is covered. The description adds a genuine trait not present in the annotations or schema: results are capped at 1,000 groups, which matters for planning grouped queries. It stops short of describing ordering or behavior when the cap is hit.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two sentences, zero waste, with the operation stated first and the result cap front-loaded as the second point. Nothing is padded or repeated.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With an output schema present and full parameter coverage, the description only needs to frame the operation and any behavioral limits, which it does via the 1,000-group cap. It could go further on result ordering or the relationship to query_sql, but nothing essential for a correct call is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so every parameter, including the where-clause operators and the count-without-column case, is already documented in the schema. The description restates the function list and the optional filter/grouping at a high level without adding syntax or edge-case meaning. Baseline 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description names a specific verb ('Computes') and the exact resource (count/sum/avg/min/max over one table or view), so the operation is unambiguous. It does not, however, distinguish itself from siblings like query_sql or execute_sql, which an agent could reasonably consider for the same task.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is only implied: the mention of 'one table or view' plus optional filter and grouping suggests when the tool fits, but there is no explicit 'use this instead of query_sql when you need aggregates' routing. No prerequisites, exclusions, or alternatives are named.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
delete_rowsADestructive
Deletes the rows of one table that match a where filter. Rolls back a call that changes more than 100 rows, triggers and cascades included. Returns rowsAffected and up to 1,000 returning rows.
| Name | Required | Description | Default |
|---|---|---|---|
| table | Yes | The table or view to delete from, by exact name. | |
| where | Yes | Conditions joined by AND that pick the rows to delete. | |
| schema | No | The schema, by exact name. Defaults to public. | |
| database | Yes | The database to run on. | |
| returning | No | Columns to return from each changed row. Omit to return none. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | The result rows, each an array in column order. |
| columns | Yes | The result columns, in order. |
| rowsAffected | Yes | The number of rows changed. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Goes well beyond the annotations by disclosing the automatic rollback above 100 affected rows and that triggers and cascades are included in that count – a critical guard an agent cannot infer from destructiveHint=true alone. It also previews the return shape (rowsAffected plus up to 1,000 returning rows).
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Three short sentences, front-loaded with the action and filter, then the safety guard, then the return contract. No filler and no repetition of the name or title.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return values need not be fully explained, and the description still summarizes them. The only minor gap is that it never mentions permissions or which alternative tools overlap with this mutation.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so the parameters are already fully documented and the description only echoes 'where filter' and the returning cap. It adds no syntax or semantics the schema does not cover, so the baseline 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource with precise scope: 'Deletes the rows of one table that match a where filter.' That framing distinguishes it from update_rows, read_rows and insert_rows without needing to name them, and rules out multi-table or unfiltered deletion.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is implied by the verb and the required where clause, but there is no explicit guidance such as when to prefer update_rows over delete+insert, or when execute_sql is the better route. The 100-row rollback is a safety behavior rather than a when-to-use rule.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
describe_tableARead-onlyIdempotent
Describes the columns of one table or view that the read login may use: name, type, nullable, default and primary key.
| Name | Required | Description | Default |
|---|---|---|---|
| table | Yes | The table or view to describe, by exact name. | |
| schema | No | The schema, by exact name. Defaults to public. | |
| database | Yes | The database to run on. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | The result rows, each an array in column order. |
| columns | Yes | The result columns, in order. |
| truncated | Yes | True when the row cap, the byte cap or limit cut off more rows. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnly, idempotent, non-destructive and closed-world, so the safety profile is covered. The description adds a genuine behavioral trait beyond them: only objects visible to the read login are described, so absence of a table is not proof it does not exist. It does not discuss behavior for a nonexistent table or ambiguous schema, but that is a minor gap given the annotation coverage.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
A single sentence that front-loads the verb and scope and then lists the returned attributes, with no filler or redundant restatement of the tool name.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The tool is simple, parameters are fully documented, an output schema covers the return shape, and annotations cover safety, so the description is nearly sufficient as-is. The only missing piece is guidance on failure or not-found behavior and how it relates to sibling introspection tools.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, with the table, schema and database parameters each documented, including the public schema default and the single-value database enum. The description adds only the clarification that the table argument may also be a view, which the schema already states, so baseline 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb (describes) and resource (columns of one table or view) and enumerates the returned attributes: name, type, nullable, default, primary key. The singular scope distinguishes it implicitly from list_tables/list_schemas, but no sibling is named explicitly.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is only implied: an agent can infer this is the follow-up step to list_tables when column-level detail is needed. There is no explicit when-to-use statement, no exclusion such as 'do not use to fetch row data – use read_rows', and no prerequisite about knowing the exact table name.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
execute_ddlADestructive
Runs one DDL statement, such as CREATE, ALTER or DROP, or a TRUNCATE. Rolls back a statement that only works on rows, which needs execute_sql, and one that changes more than 100 rows in tables that existed before it. Returns at most 1,000 rows. Times out after 30s.
| Name | Required | Description | Default |
|---|---|---|---|
| sql | Yes | One SQL statement, sent to Postgres unchanged. More than one statement is refused. | |
| database | Yes | The database to run on. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | The result rows, each an array in column order. |
| columns | Yes | The result columns, in order. |
| rowsAffected | Yes | The number of rows changed. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare destructiveHint=true and idempotentHint=false, and the description goes well beyond them: rollback semantics for disallowed statements, a 100-row mutation ceiling applied only to pre-existing tables, a 1,000-row return cap, and a 30s timeout. These are exactly the operational limits an agent needs before firing a destructive call.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Four short sentences, front-loaded with the operation type before limits and caveats, with no filler. The rollback sentences are slightly convoluted, especially 'one that changes more than 100 rows in tables that existed before it', which takes a reread to parse.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return values need no explanation; the description instead covers the operational envelope (timeout, row caps, rollback guards) that determines whether the call succeeds. For a destructive two-parameter tool, nothing critical is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100%, so the single-statement constraint, the database enum, and the 'sent to Postgres unchanged' note are all already in the schema. The description adds no parameter-specific detail, so the baseline 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
Names the exact operation family ('one DDL statement') and enumerates concrete examples (CREATE, ALTER, DROP, TRUNCATE), which immediately separates it from row-oriented siblings. An agent can tell this is for schema changes, not data changes, without opening the schema.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly routes row-only statements to execute_sql and states the two rejection conditions (row-only statements, mutations exceeding 100 rows in pre-existing tables). This is real when-to-use/when-not-to-use guidance tied to a named alternative.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
execute_sqlADestructive
Runs one SQL statement that works on rows, such as INSERT, UPDATE, DELETE, MERGE, CALL or DO. Rolls back TRUNCATE and changes of structure, which need execute_ddl, and a statement that changes more than 100 rows across all tables. Returns at most 1,000 rows. Times out after 30s.
| Name | Required | Description | Default |
|---|---|---|---|
| sql | Yes | One SQL statement, sent to Postgres unchanged. More than one statement is refused. | |
| database | Yes | The database to run on. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | The result rows, each an array in column order. |
| columns | Yes | The result columns, in order. |
| rowsAffected | Yes | The number of rows changed. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare destructive/not-readonly, so the description's job is to add operational detail, and it does: statements over 100 affected rows across all tables are rejected, results are capped at 1,000 rows, and execution times out at 30s. Those are exactly the constraints an agent needs before composing a statement, and none of them are in the annotations or schema.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Four short sentences, front-loaded with the core action, then constraints in descending order of importance (DDL exclusion, row-impact cap, result cap, timeout). No filler and nothing repeated.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With an output schema present, return-value documentation is unnecessary, and the description still supplies limits, timeouts, and rejection behavior, so it is nearly complete for a single-parameter mutation tool. The one residual gap is that 'Rolls back TRUNCATE...' does not explicitly say the statement is refused/aborted rather than silently attempting and reverting, which could be read either way.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100% and both parameters are already documented ('One SQL statement, sent to Postgres unchanged'; the database enum). The description reinforces the single-statement constraint ('Runs one SQL statement') but adds no new syntax, dialect, or format guidance beyond the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb and resource ('Runs one SQL statement that works on rows') and enumerates the statement families it accepts (INSERT, UPDATE, DELETE, MERGE, CALL, DO), which is far more than a restatement of the name. It cleanly distinguishes itself from execute_ddl. It does not, however, distinguish itself from query_sql for reads or from insert_rows/update_rows/delete_rows for single-row work, leaving some sibling ambiguity.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It gives one explicit routing rule: TRUNCATE and structure changes 'need execute_ddl', which tells the agent precisely when to pick a different tool. It also implies a row-count boundary (>100 rows across all tables is refused) as a reason to fall back to other tools. It stops short of naming query_sql as the read alternative or the row-level CRUD siblings as safer options.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
insert_rowsADestructive
Inserts rows into one table. Rolls back a call that changes more than 100 rows, triggers and cascades included. Returns rowsAffected and up to 1,000 returning rows.
| Name | Required | Description | Default |
|---|---|---|---|
| rows | Yes | The rows to insert. An omitted column takes its default. | |
| table | Yes | The table or view to insert into, by exact name. | |
| schema | No | The schema, by exact name. Defaults to public. | |
| database | Yes | The database to run on. | |
| returning | No | Columns to return from each changed row. Omit to return none. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | The result rows, each an array in column order. |
| columns | Yes | The result columns, in order. |
| rowsAffected | Yes | The number of rows changed. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already flag destructiveHint=true and the absence of idempotency, so the safety profile is largely covered. The description adds genuinely new behavioral facts: a hard rollback threshold at 100 affected rows, that triggers and cascades count toward that limit, and that the call returns rowsAffected plus capped returning rows.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Three sentences, zero filler, with the operation stated first and the consequential rollback limit immediately after. Nothing here could be removed without losing information.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return shape doesn't need explaining, and the 100% parameter coverage handles the inputs. What remains — the silent rollback guardrail, trigger/cascade counting, and single-table restriction — is exactly the non-obvious information an agent needs before calling a destructive write.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so every parameter including rows, table, schema, database, and returning is already documented with defaults. The description adds only the 1,000-row cap on the returning output, which is a marginal contribution beyond the schema's baseline.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ("Inserts rows into one table") and bounds scope to a single table, which separates it in practice from update_rows, delete_rows, and the free-form execute_sql. It never names those siblings explicitly, which keeps it short of a 5.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is implied by the verb: use this to add rows rather than to modify or remove them. There is no explicit when-to-use or when-not guidance, and no steer toward execute_sql for cases this tool can't express (e.g. multi-table inserts).
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_databasesARead-onlyIdempotent
Lists the databases the caller may use, 25 per page. Pass an object's value as database to the other tools.
| Name | Required | Description | Default |
|---|---|---|---|
| cursor | No | The nextCursor of the previous page. Omit for the first page. |
Output Schema
| Name | Required | Description |
|---|---|---|
| objects | Yes | The databases on this page. |
| nextCursor | No | Set when more follow. Pass it as cursor for the next page. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnly, idempotent, non-destructive and closed-world, so the safety profile is covered. The description adds two things annotations cannot: results are permission-scoped to the caller and paginated at 25 per page, which directly affects how an agent must loop. It does not mention termination conditions or total counts, but an output schema exists to cover return shape.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two sentences, zero filler, and the core identity (what is listed and under what access scope) is front-loaded before the pagination and hand-off details. Every clause carries information an agent needs.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a one-parameter, read-only discovery tool with an output schema, the description covers access scope, page size, and how to reuse the returned values downstream. It is nearly complete; only the lack of explicit ordering relative to the other list_* tools leaves a small gap.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100% and the single cursor parameter is already documented as 'the nextCursor of the previous page.' The description's '25 per page' adds useful page-size context that shapes cursor looping, but it adds no new meaning about the cursor parameter itself. Baseline 3 is appropriate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ('Lists the databases') plus an access qualifier ('the caller may use'), which cleanly separates it from list_schemas and list_tables in the sibling set. It stops short of naming those siblings explicitly, so an agent must infer the hierarchy rather than being told.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The second sentence gives downstream usage ('Pass an object's value as database to the other tools'), which is genuinely useful routing guidance. However, it never states when to call this versus list_schemas/list_tables, nor that this is the entry point before schema or table discovery — that ordering is only implied.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_schemasARead-onlyIdempotent
Lists the schemas that the read login may use, without the system schemas, 100 per page.
| Name | Required | Description | Default |
|---|---|---|---|
| cursor | No | The nextCursor of the previous list_schemas page. Omit for the first page. | |
| database | Yes | The database to run on. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | The result rows, each an array in column order. |
| columns | Yes | The result columns, in order. |
| truncated | Yes | True when the row cap, the byte cap or limit cut off more rows. |
| nextCursor | No | Set when more follow. Pass it as cursor for the next page. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already cover readOnly, idempotent, non-destructive, and closed-world, so the safety profile is done. The description still earns credit by disclosing behavior the annotations do not: results are privilege-filtered to the read login, system schemas are suppressed, and pages are capped at 100 rows.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
A single front-loaded sentence carrying the scope filter and the page-size constraint with zero filler. Nothing to trim and nothing important buried.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Appropriate for a simple two-parameter listing tool: an output schema handles return shape and annotations handle safety, so the description only needs scope and pagination, which it supplies. Minor gap: no mention of result ordering or what happens when the cursor expires.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, and both parameters (cursor with first-page guidance, database with an enum) are documented in the schema itself. The description's "100 per page" gives indirect context for pagination but adds no per-parameter meaning beyond what the schema already states.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ("Lists the schemas") plus two scope qualifiers that matter operationally: only schemas the read login may access, and system schemas excluded. The resource name alone separates it from siblings like list_tables and list_databases, so an agent can route without opening the schema.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is implied by the name and scope (use to enumerate schemas before describing or querying tables), but there is no explicit when-to-use statement, no named alternative, and no note about whether to call this before list_tables. Adequate but relies on inference.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_tablesARead-onlyIdempotent
Lists the tables, views, materialized views and foreign tables that the read login may use, with schema, name and kind, 100 per page.
| Name | Required | Description | Default |
|---|---|---|---|
| cursor | No | The nextCursor of the previous list_tables page. Omit for the first page. | |
| schema | No | The schema to list, by exact name. Omit for every schema. | |
| database | Yes | The database to run on. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | The result rows, each an array in column order. |
| columns | Yes | The result columns, in order. |
| truncated | Yes | True when the row cap, the byte cap or limit cut off more rows. |
| nextCursor | No | Set when more follow. Pass it as cursor for the next page. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnly, idempotent, non-destructive and closed-world, so the safety profile is covered. The description adds meaningful context beyond that: results are permission-scoped to the read login, and results are paginated at 100 per page. It stops short of stating ordering, total counts, or whether system schemas appear.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
A single sentence with zero filler; the resource scope and the returned fields are front-loaded, with the pagination detail trailing. Nothing is repeated from the name or title, and nothing could be cut without losing information.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return-shape explanation is unnecessary, and the description still names the key returned fields. Pagination and the permission scope are both covered for a low-complexity, three-parameter read tool. Only result ordering and default schema behavior are left unstated, which is minor.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100%, so each parameter including cursor, schema and database is already documented in the schema. The description's only added semantic is the 100-per-page pagination cadence, which implies the cursor workflow but does not explain it further. Baseline 3 applies when the schema does the heavy lifting.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description uses a specific verb ('Lists') with a precisely enumerated resource: tables, views, materialized views and foreign tables. It also states the returned fields (schema, name, kind) and the page size. It does not explicitly contrast itself with siblings like list_schemas or describe_table, leaving that inference to the caller.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is only implied: an agent can infer this is the discovery step before describe_table or read_rows, and that it lists only objects visible to the read login. There is no explicit when-to-use, when-not-to-use, or named alternative (e.g. list_schemas for schema-level discovery).
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
query_sqlARead-onlyIdempotent
Runs one SQL statement in a read-only transaction, so it cannot change data. Returns at most 1,000 rows and 1,048,576 bytes. Times out after 30s.
| Name | Required | Description | Default |
|---|---|---|---|
| sql | Yes | One SQL statement, sent to Postgres unchanged. More than one statement is refused. | |
| database | Yes | The database to run on. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | The result rows, each an array in column order. |
| columns | Yes | The result columns, in order. |
| truncated | Yes | True when the row cap, the byte cap or limit cut off more rows. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The annotations already declare readOnlyHint, idempotentHint, and destructiveHint=false, so the safety profile is structured. The description still adds behavior annotations cannot express: a 1,000-row cap, a 1,048,576-byte cap, and a 30s timeout — exactly the operational limits an agent needs to anticipate truncated results. It stops short of saying whether hitting a cap truncates silently or errors, which is the one missing piece.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Three short sentences, front-loaded with the operation and its safety guarantee, followed by concrete limits. No filler and nothing repeated from the title or schema verbatim.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return values need not be explained, and the description covers the mutation-safety question and the resource caps. Relative to a two-parameter query tool that is nearly complete; only the truncation-vs-error behavior at the row/byte limits is left unspecified.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100%, so both parameters are already documented, including the single-statement constraint and the database enum. The description's "one SQL statement" and the read-only framing add emphasis but no meaning beyond the schema, so the baseline 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ("Runs one SQL statement") plus a scope qualifier ("in a read-only transaction"), which is much stronger than a name restatement. It does not distinguish itself from the near-identical siblings execute_sql and execute_ddl, nor from aggregate/read_rows, so an agent cannot tell from the description alone which of those overlapping tools to pick.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
"so it cannot change data" implies the tool is for read queries, but there is no explicit when-to-use instruction. With eleven siblings including execute_sql, execute_ddl, aggregate, and read_rows, the absence of any routing guidance (e.g. "use aggregate for GROUP BY summarization") is a real gap.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
read_rowsARead-onlyIdempotent
Reads rows of one table or view, with an optional filter, order and limit. Returns at most 1,000 rows and 1,048,576 bytes.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | The most rows to return. Defaults to the row cap of 1,000. | |
| table | Yes | The table or view to read, by exact name. | |
| where | No | Conditions joined by AND. Omit to read every row. | |
| schema | No | The schema, by exact name. Defaults to public. | |
| columns | No | Columns to return, in order. Omit for every column the login may select. | |
| database | Yes | The database to run on. | |
| order_by | No | The sort order, first entry first. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | The result rows, each an array in column order. |
| columns | Yes | The result columns, in order. |
| truncated | Yes | True when the row cap, the byte cap or limit cut off more rows. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnly, idempotent, non-destructive and closed-world, so the safety profile is covered. The description adds genuinely useful behavioral facts the annotations lack: hard caps of 1,000 rows and 1,048,576 bytes. It does not say whether hitting those caps truncates silently or raises an error, which keeps it below a 5.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two sentences, zero filler, with the operation stated first and the return-size ceiling second. Nothing redundant or buried.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With an output schema present, return values need no explanation, and all seven parameters are fully described in the schema. The remaining gap is behavioral edge cases around the row/byte caps and the permission scoping implied by "every column the login may select."
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so the baseline is 3, and the description only echoes the schema ("optional filter, order and limit"). It adds no extra meaning about default schema, column projection, or operator behavior beyond what the schema already documents.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description gives a specific verb and resource ("Reads rows of one table or view") plus the three optional capabilities (filter, order, limit), so an agent immediately knows the shape of the operation. It stops short of naming a sibling such as query_sql or aggregate, so differentiation is left to inference.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
There is no statement of when to reach for this tool over query_sql, execute_sql, aggregate, or the write siblings like insert_rows/update_rows. The only conditional guidance ("Omit to read every row") is about a parameter default rather than tool selection.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
update_rowsADestructive
Updates the rows of one table that match a where filter. Rolls back a call that changes more than 100 rows, triggers and cascades included. Returns rowsAffected and up to 1,000 returning rows.
| Name | Required | Description | Default |
|---|---|---|---|
| set | Yes | The columns to set and their new values. | |
| table | Yes | The table or view to update, by exact name. | |
| where | Yes | Conditions joined by AND that pick the rows to update. | |
| schema | No | The schema, by exact name. Defaults to public. | |
| database | Yes | The database to run on. | |
| returning | No | Columns to return from each changed row. Omit to return none. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | The result rows, each an array in column order. |
| columns | Yes | The result columns, in order. |
| rowsAffected | Yes | The number of rows changed. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Adds real behavioral context beyond the annotations: an automatic rollback when more than 100 rows would change, and the warning that triggers and cascades are included in that count. This is exactly the kind of side-effect disclosure an agent needs before a destructive mutation, and it complements rather than repeats destructiveHint=true.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Three short sentences with zero filler: scope first, safety guard second, return contract third. Every clause carries information an agent would otherwise have to discover by trial.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With an output schema present and rich annotations, the description covers the essentials: mutation scope, the rollback threshold, cascade behavior, and return shape. Minor omissions such as transaction/atomicity semantics or permission requirements keep it from being fully complete.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so the parameters are already well documented. The description adds only one meaningful detail beyond the schema — the 1,000-row cap on 'returning' — which is useful but limited; the baseline of 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ('Updates the rows of one table that match a where filter'), scoping it to a single table and a filter predicate. It does not name or distinguish itself from siblings like execute_sql or insert_rows, so the agent must infer the boundary from the name alone.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description implies usage through the 'where filter' phrasing but never states when to choose this over execute_sql or execute_ddl, nor any prerequisites. The when-to-use case is inferable from the tool name but not spelled out.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
12 tool updates
- First observed
aggregate - First observed
delete_rows - First observed
describe_table - First observed
execute_ddl - First observed
execute_sql - First observed
insert_rows - First observed
list_databases - First observed
list_schemas - First observed
list_tables - First observed
query_sql - First observed
read_rows - First observed
update_rows
TDQS
Scored across 12 tools
Each tool has a stated purpose and the descriptions explicitly carve boundaries (execute_sql rejects TRUNCATE/structure changes; execute_ddl rejects row-only statements; query_sql is read-only). The main overlap is execute_sql versus insert_rows/update_rows/delete_rows and query_sql versus read_rows/aggregate, where an agent could reasonably pick either, though the specialized tools are clearly the safer/convenience option.
Nearly all names follow a verb_noun pattern (list_tables, read_rows, insert_rows, update_rows, delete_rows, execute_sql, describe_table), which is predictable and readable. The only deviation is the bare verb 'aggregate', which lacks a noun target.
Twelve tools is a well-scoped surface for a PostgreSQL server: discovery (list_databases/schemas/tables), inspection (describe_table), reads (read_rows, query_sql, aggregate), and writes (insert/update/delete/execute_sql/execute_ddl). No tool appears redundant enough to drop.
CRUD, DDL, arbitrary read SQL, aggregation, and schema/database discovery are all covered, so core lifecycle workflows have no dead ends. Gaps remain for transaction control (begin/commit/rollback across statements) and query plan introspection (EXPLAIN), and pagination beyond the first page is only implied for the list_* tools.
Maintenance
Related MCP Connectors
Managed Postgres MCP: projects, SQL, docs search, and storage buckets via OAuth.
Text-to-SQL MCP server: read-only queries on PostgreSQL, MySQL and SQL Server with your real schema
Hosted MCP server for PostgreSQL diagnostics: slow queries, missing indexes, connection pressure.
Hosted PostgreSQL MCP server for Claude, ChatGPT and Cursor, with each action switched per account.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceAn open-source MCP server for PostgreSQL schema introspection and guarded read-only queries. It enables MCP clients to discover schemas, tables, columns, indexes, relationships, and safe queryable data from a configured PostgreSQL database.11 npmMIT
- AlicenseNot gradedqualityDmaintenanceMCP server for PostgreSQL that enables Cursor and other MCP clients to execute SQL queries, list tables, and explore database schemas via HTTP.1,240 npmMIT
- AlicenseAqualityDmaintenanceA self-hostable PostgreSQL MCP server for exploring database schemas and running guarded read/write queries with selectable access modes (readonly, readwrite, admin), plus a dry-run confirm workflow for safety.141MIT
- AlicenseAqualityCmaintenanceMCP server for PostgreSQL, providing schema discovery, catalog introspection, read-only querying, and optional safe write operations to AI assistants.830 npmPolyForm Noncommercial 1.0.0