pgsteward
Read-only access to a fleet of PostgreSQL databases for LLM agents, over MCP. The agent picks a connection by id and never sees the credentials.
Claude, Cursor, opencode or any other MCP client can explore your schema, run queries, read execution plans and check database health from the chat.
Tools
| Tool | What it does |
|---|---|
list_connections |
Configured connections: id, read_only, environment. No network access unless probe=true. |
list_databases |
Databases on this connection's server. |
list_schemas |
Schemas in this connection's database (system schemas excluded). |
list_tables |
Tables and views in a schema. |
describe_table |
Columns, types, nullability, defaults, PK, FKs, indexes, row-count estimate. Errors if the relation does not exist. |
search_schema |
Substring search for tables and columns across every non-system schema; reports truncation. |
query |
One guarded SQL statement, positional parameters, server-side row cap. |
explain_query |
EXPLAIN (FORMAT JSON) as a parsed plan tree with heuristic warnings. |
analyze_db_health |
Eleven catalog checks; no extensions required, so it works on managed PostgreSQL. |
ping |
Connection reachability. |
get_server_info |
Server version and current database. |
get_security_config |
Effective policy: read_only, DML/DDL, max_rows, max_bytes, query_timeout. |
Two more — describe_relationships and federated_lookup — join an entity
that lives in several databases under different keys. They are registered
only when a relationship map is configured, so they cost no context in a
session that has no such map.
Every tool returns a JSON string. describe_table, abridged:
{
"name": "customer_order", "schema": "public",
"columns": [
{"name": "id", "data_type": "bigint", "nullable": false, "is_primary_key": true,
"default": "nextval('customer_order_id_seq'::regclass)"},
{"name": "customer_id", "data_type": "bigint", "nullable": false,
"default": null, "is_primary_key": false},
{"name": "total", "data_type": "numeric(12,2)", "nullable": false,
"default": null, "is_primary_key": false}
],
"primary_key": ["id"],
"foreign_keys": [
{"column": "customer_id", "references_schema": "public",
"references_table": "customer", "references_column": "id",
"constraint_name": "customer_order_customer_id_fkey"}
],
"indexes": [
{"name": "idx_order_customer", "columns": ["customer_id"],
"is_unique": false, "included_columns": []}
],
"extra": {"estimated_row_count": 28431, "relation_kind": "table"},
"notes": []
}
Response shapes, health-check thresholds and the privilege matrix: docs/tools.md.
What this is for
pgsteward is built around one setup: an agent working with several PostgreSQL databases in a single session — staging and production, or several services. Four properties follow from that.
Read-only is enforced by PostgreSQL, not by the SQL classifier. Every
read runs inside BEGIN TRANSACTION READ ONLY, on top of
default_transaction_read_only and a server-side statement_timeout.
Statements that only look like reads — WITH ... DELETE ... RETURNING,
EXPLAIN ANALYZE INSERT — are rejected by the engine
(asserted against a live server).
Several connections, credentials on the server. The agent picks a
connection_id and never sees a DSN. Connection failures come back as a
stable code — auth_failed, database_not_found, unreachable — so the
host names, ports and role names in driver messages stay in the server log
instead of entering the model's context.
Health checks that refuse to guess. Under a least-privilege role
PostgreSQL blanks the columns these checks need instead of raising an
error, so a check that trusts the result reports ok having examined
nothing. pgsteward returns skipped and names the missing grant.
An audit log with no SQL in it. Every tool call logs the connection, statement class, duration, row count and outcome — failures included, since a refused write is worth as much to an audit as a successful read. The statement text and the parameter values are not written anywhere.
Install
With uv there is nothing to install ahead of
time: your MCP client can run uvx pgsteward directly.
uv tool install pgsteward # or: pipx install pgsteward
Requires Python 3.11+ and a reachable PostgreSQL 14+. CI exercises 14 and 17 against a live server. 14 is the oldest release still supported upstream; older servers are likely to work, but nothing verifies that.
Quick start
1. Point pgsteward at a database. Configuration is entirely environment
variables — one line of metadata plus a block of credentials. Put them in
~/.config/pgsteward/.env, which pgsteward finds on its own:
mkdir -p ~/.config/pgsteward
touch ~/.config/pgsteward/.env && chmod 600 ~/.config/pgsteward/.env
PGSTEWARD_DB_CONNECTIONS=web-shop:WEB_SHOP
WEB_SHOP_PG_HOST=127.0.0.1
WEB_SHOP_PG_PORT=5432
WEB_SHOP_PG_DATABASE=web_shop
WEB_SHOP_PG_USER=readonly_user
WEB_SHOP_PG_PASSWORD=secret
web-shop is the id the agent uses; WEB_SHOP is the prefix of the
credentials block. HOST, USER and DATABASE are required — none of
them is guessed. Read-only and write-forbidden are the defaults.
Leave WEB_SHOP_PG_PASSWORD empty and no password is sent at all, so a
Unix socket with peer, a TLS client certificate, .pgpass or an IAM
token work instead. See
docs/security.md.
2. Register it with your MCP client. For Claude Code:
claude mcp add pgsteward -- uvx pgsteward
For anything with a JSON config (Claude Desktop, Cursor, opencode, VS Code, Windsurf, Zed) the shape is the same, and carries no credentials:
{
"mcpServers": {
"pgsteward": {
"command": "uvx",
"args": ["pgsteward"]
}
}
}
Exact file paths and per-client quirks: docs/integrations.md.
The variables can go in the client's env block instead of the .env, and
for a throwaway local database that is fine. For anything else the .env
is the safer of the two — see
docs/security.md.
3. Ask. "Which tables reference the customer id?", "Why is this query slow?", "Is anything wrong with this database right now?"
To try it without a real database, examples/quickstart brings up a seeded PostgreSQL in Docker with a ready-made config.
Security model
Reads are protected at three levels, and only the last two are guarantees:
- Classification before execution. The statement is classified by its first significant keyword and checked against the policy; more than one statement per call is rejected. This is a filter: a keyword classifier can be fooled, so the guarantee rests on layer 2.
- The transaction. Reads always execute inside
BEGIN TRANSACTION READ ONLY, whatever the connection flags say. - The session. Read-only connections also set
default_transaction_read_only=on, and every connection sets a server-sidestatement_timeout.
Writes require both read_only=false on the connection and the matching
class enabled in policy. Both default to off.
Point it at a least-privilege role rather than a superuser. pgsteward does not rewrite the values it returns, so keeping a column — personal data, a secret — away from the agent is done with column-level grants or a view, and enforced by PostgreSQL: hiding columns from the agent.
How each layer is implemented and where it stops: docs/security.md. What pgsteward does not protect against, and how to deploy it safely: SECURITY.md.
Configuration
PGSTEWARD_DB_CONNECTIONS=web-shop:WEB_SHOP,analytics:ANALYTICS
CSV of id:prefix. prefix defaults to the id uppercased, with dashes
and spaces turned into underscores. Credentials are then read from
<PREFIX>_PG_HOST / USER / DATABASE — all three required, unless
<PREFIX>_PG_DSN supplies the address whole — plus optional PORT,
PASSWORD, READ_ONLY, SSLMODE, QUERY_TIMEOUT, MAX_ROWS,
MAX_BYTES, POOL_MIN, POOL_MAX, ALLOW_DML, ALLOW_DDL.
An id ending in one of the suffixes you list in PGSTEWARD_ENV_SUFFIXES
(prod,staging, dev,uat — whichever your ids use) reports that
suffix as its environment in list_connections, so the agent can tell
production apart before it queries it. It is a label, not a guard — writes
are stopped by read_only and the policy, never by the name.
Full reference, including how .env is resolved when pgsteward is installed
from PyPI: docs/configuration.md.
Scope
PostgreSQL only, and that is a decision rather than a current state: the
health checks, the EXPLAIN (FORMAT JSON) parser and the READ ONLY
barrier the security model rests on are all PostgreSQL features with no
equivalent elsewhere. There is no engine field in the configuration and no
engine in the responses. The reasoning in full:
docs/architecture.md.
Transport is stdio. The MCP client starts pgsteward as a child process on demand: there is no service to deploy, and no SSE or HTTP endpoint either.
Not included, so that you can rule pgsteward out quickly: index
recommendations, pg_stat_statements workload analysis, and table or
index bloat estimates.
Development
git clone https://github.com/olegpydev/pgsteward && cd pgsteward
uv sync
uv run ruff check . && uv run ruff format --check .
uv run mypy pgsteward
uv run pytest -m "not integration"
Integration tests need a throwaway PostgreSQL and are skipped without one; the command to start it is in CONTRIBUTING.md. What each suite is responsible for: docs/architecture.md.
Documentation
| Document | Covers |
|---|---|
| docs/configuration.md | Every environment variable, .env resolution, multiple environments |
| docs/tools.md | Response shapes, health-check thresholds, required privileges |
| docs/security.md | How the three layers work, known gaps, hiding columns, audit log |
| docs/integrations.md | Per-client config files and troubleshooting |
| docs/architecture.md | Layout, request path, adapter boundary, testing strategy |
| SECURITY.md | Threat model and recommended deployment |
License
MIT — see LICENSE.
Metadata
Release files for pgsteward 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pgsteward-0.1.0.tar.gz | 308.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pgsteward-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 381.0 kB
Release files / pgsteward-0.1.0.tar.gz
| Download URL | pgsteward-0.1.0.tar.gz |
|---|---|
| Size | 308.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9b3597ce505866eed15cab9028b3dd19cf7896c58de983ce528a34b87db92523
|
|
BLAKE2b-256 checksum How to use checksums |
977f36340c39e91f5b924e554e3a695288b8a041b09bb9cfa4d99ef8a8df1b2e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.
Transparency logRelease files / pgsteward-0.1.0-py3-none-any.whl
| Download URL | pgsteward-0.1.0-py3-none-any.whl |
|---|---|
| Size | 72.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
efa824ec4ecbc41555c50d2f395d100dbf55b9820ce88965ff4ce36fd24d59d8
|
|
BLAKE2b-256 checksum How to use checksums |
9769c92deb424ee597e737e14798658647c7ae8889249aae9ee20cad354e94f5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.
Transparency log