pgwarden-mcp
Provides AI agents with a secure, policy-enforced interface to PostgreSQL databases, allowing read-only queries with allowlisted schemas, tables, and columns, row limits, PII masking, and multi-database routing.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@pgwarden-mcpRun a health check on our main database and suggest index improvements"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
pgwarden-mcp
A Postgres MCP server that an LLM agent can point at production without reading your customers' data.
pgwarden-mcp puts a deny-by-default policy layer between an AI agent and a Postgres database: a YAML file allowlists schemas, tables and columns, caps how many rows a single query may return, requires that certain columns be constrained, masks PII on the way out, and routes one server across several databases.
It is a hard fork of crystaldba/postgres-mcp and keeps everything that server does - index tuning, health checks, top-query analysis, EXPLAIN plans.
Why this exists
An MCP server hands an LLM agent a database connection. Access modes stop the agent from writing, but a read-only agent connected to a production database can still:
Exfiltrate PII.
SELECT email, phone, national_id FROM customersis a perfectly ordinary read. The values land in the model's context, in the client's chat history, and in whatever logs sit along that path.Sweep a multi-tenant table. A query the user meant as "my orders" is one forgotten
WHERE tenant_id = ...away from being "everyone's orders". The agent has no way to know the difference, and the database will happily answer. (This layer can insist the predicate is there. Deciding which tenant it may name is Postgres's job, not this layer's.)Pull whole tables into a context window.
SELECT * FROM eventson a table with 40 million rows is a denial-of-wallet event at best, and it is one prompt away at all times.Read the server's own furniture.
pg_authid,pg_shadow,pg_stat_activityand friends expose password hashes, role membership and every other session's SQL text - none of which are application data, all of which are reachable from an ordinary read-only connection.Be talked into it. Prompt injection through data the agent reads (a support ticket body, a user-supplied name) can turn any of the above into an instruction the agent follows.
The policy layer is a deny-by-default filter between the agent and the database, applied by parsing every statement with pglast before it is sent. It is not a substitute for Postgres roles and RLS - see Security notes and limitations - it is the layer that stops the ordinary, unmalicious version of each of the failures above, and it produces errors an agent can read and act on.
Related MCP server: Postgres MCP Pro
Fork notice and attribution
This project is a hard fork of crystaldba/postgres-mcp ("Postgres MCP Pro"), created and maintained by Crystal DBA and originally authored by Johann Schleier-Smith. Upstream is MIT licensed; this fork keeps that license and upstream's copyright notice intact - see LICENSE.
Everything in Inherited capabilities is upstream's work: the index tuning advisor, the database health checks, the query-plan tooling, the read-only SQL execution driver, and the MCP tool surface they hang off. What this fork adds is the policy layer described in the rest of this document, plus multi-database routing.
The fork is renamed rather than versioned on top of upstream: the distribution and CLI are pgwarden-mcp, the Python package is pgwarden_mcp, environment variables are PGWARDEN_*, and version numbering restarts at 0.1.0.
Upstream's health checks are in turn adapted from PgHero, and the index advisor follows Microsoft's Anytime Algorithm.
Quickstart
Install
The distribution is not published to PyPI or Docker Hub yet, so install from source:
git clone https://github.com/gokiwitech/pgwarden-mcp.git pgwarden-mcp
cd pgwarden-mcp
uv sync
uv run pgwarden-mcp --helpOr build the container image locally:
docker build -t pgwarden-mcp .Python 3.12 or newer is required. If you need uv, see the uv installation instructions.
1. Write a policy file
# policy.yaml
databases:
main:
connection_url: "postgresql://${PGUSER}:${PGPASSWORD}@localhost:5432/app"
allowed_schemas: [public]
tables:
orders: {}
customers:
columns:
mode: exclude
list: [password_hash]
require_predicate_on: [tenant_id]
mask:
email:
strategy: email${VAR} references are substituted from the process environment after the YAML is parsed, so a value containing quotes or newlines can only ever become the content of the scalar that referenced it - it can never add or change a policy key.
A reference to an unset variable is a startup error.
Keep credentials and salts out of the file.
A fully commented configuration, including a second database and every masking strategy, is in examples/policy.example.yaml.
2. Start the server
export PGUSER=app_readonly PGPASSWORD=...
uv run pgwarden-mcp --config ./policy.yamlor set PGWARDEN_CONFIG=/path/to/policy.yaml (the flag wins if both are given).
With a policy file, PGWARDEN_DATABASE_URI and the positional connection URL are not consulted at all - every connection comes from the policy file.
A policy file that cannot be read, parsed or validated is a fatal startup error, on purpose.
--config also implies read-only: a policy can only be enforced by parsing the SQL, so supplying a config file selects the parsing driver even under the default --access-mode=unrestricted. See Access modes.
3. Connect an MCP client
For Claude Desktop, edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%/Claude/claude_desktop_config.json (Windows):
{
"mcpServers": {
"pgwarden": {
"command": "pgwarden-mcp",
"args": ["--config", "/etc/pgwarden/policy.yaml"],
"env": {
"PGUSER": "app_readonly",
"PGPASSWORD": "...",
"PGWARDEN_MASK_SALT": "..."
}
}
}
}With Docker, mount the policy file and pass the same flag:
{
"mcpServers": {
"pgwarden": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-v", "/etc/pgwarden:/etc/pgwarden:ro",
"-e", "PGUSER", "-e", "PGPASSWORD", "-e", "PGWARDEN_MASK_SALT",
"pgwarden-mcp",
"--config", "/etc/pgwarden/policy.yaml"
],
"env": {
"PGUSER": "app_readonly",
"PGPASSWORD": "...",
"PGWARDEN_MASK_SALT": "..."
}
}
}
}The container entrypoint remaps a localhost host in the connection URI to host.docker.internal (Docker Desktop) or 172.17.0.1 (Linux) automatically.
Other clients use the same shape:
Cursor:
Command Palette→Cursor Settings→MCPtab.Windsurf:
Command Palette→Open Windsurf Settings Page.Goose:
goose configure, thenAdd Extension.Qodo Gen: Chat panel →
Connect more tools→+ Add new MCP.
4. What the agent can and cannot do now
Query | Result |
| Runs. |
| Rejected: |
| Rejected: the table restricts its columns, so a wildcard is refused. |
| Rejected: the column is excluded. |
| Rejected: |
| Rejected: a predicate inside an aggregate's |
| Rejected: the table is not in the allowlist. |
| Rejected: denied system catalog. |
| Rejected: the planner statistics views hold sampled values out of the tables they describe. |
| Rejected while |
| Rejected: agent SQL must be exactly one statement. Postgres runs all of them and returns only the last one's rows. |
| Rejected: a quoted mixed-case name is a different relation from |
| Rejected twice over: a policy file forces the parsing, read-only driver even under |
| Runs, rewritten to |
Environment variables
Variable | Default | Meaning |
| unset | Path to the policy file. Equivalent to |
| unset | Connection URI for a single-database run with no policy file. Ignored entirely when a config file is supplied. Falls back to the deprecated |
| unset | Salt for the |
|
| Set to |
| unset | Required only by the experimental |
Running with no policy file preserves upstream's behaviour exactly: a single database from PGWARDEN_DATABASE_URI (or the positional argument), every schema and table allowed, all system catalogs allowed, no row limits and no masking. The policy engine is not constructed at all in that mode.
What is enforced, and where
The policy is applied by parsing each statement before execution. Two different things happen, and the distinction matters.
Checks run on every statement the server sends on a policed connection - including the SQL the server's own analysis tools generate:
the statement kind: only
SELECT,EXPLAIN,SHOW,VACUUM/ANALYZEand the cursor statements (DECLARE,FETCH,CLOSE) are modelled, and everything else - DML,COPY,PREPARE/EXECUTE, all DDL - is refused rather than passed through unchecked,the schema and table allowlists, including the refusal of any relation or schema name that is not already lower case,
the system catalog denylist,
require_predicate_on,column
include/excluderules,the ban on using a masked column as a filter, join key, grouping, partitioning or ordering key - which covers a
GROUP BY GROUPING SETS/CUBE/ROLLUPkey, an aggregate'sFILTER (WHERE ...)condition, aJOIN ... USING (col)column, aNATURAL JOINover any table that carries column or mask rules, and a positional key (ORDER BY 2) whether the position belongs to a plain target list or to the merged output of a set operation,the ban on reading the value-bearing columns of
pg_statsand thepg_stats_extpair.
Rewrites and result filtering run only on SQL whose provenance is an agent:
the one-statement rule: agent SQL that holds more than one statement is refused, because Postgres executes all of them and returns only the last one's rows - a
;would otherwise smuggle a second query past both the row limit and the mask plan,row limit injection and clamping, and the refusal of
DECLARE ... CURSORthat comes with it,PII masking of the returned rows, and the refusal of any shape whose provenance cannot be resolved: a masked column inside an expression (including inside a
count(CASE WHEN ...)orcount(nullif(...)), which are predicates dressed as values), a whole-row reference, a renamed wildcard, aSELECT *arm beside a named-column arm in a set operation, and a set operation that puts a masked column and a caller-written constant in the same output position.
Provenance is a property of the SQL, not of the call site.
Exactly three places in the server run agent SQL: execute_sql, and the two ExplainPlanTool entry points that back explain_query and the plan simulation the index advisor performs on the queries handed to analyze_query_indexes.
Everything else (analyze_db_health, analyze_workload_indexes, get_top_queries, list_objects, get_object_details, list_schemas, and the statistics queries underneath them) runs server-authored SQL: injecting a LIMIT into a statistics scan would silently truncate it and masking catalog output would corrupt it, so those results are never row-limited or masked.
They are still subject to every check in the first list.
The classification is pinned by a test that enumerates every execute_query call site in src/ and fails on any new one that has not been classified (tests/unit/test_agent_sql_invariant.py).
One consequence worth knowing: explain_query is checked but not masked or limited, because EXPLAIN returns a plan rather than rows, and neither the row-limit rewrite nor the mask planner applies to a statement whose body is not a SELECT.
EXPLAIN ANALYZE does execute the query, so a require_predicate_on violation inside it is still rejected, but the plan it returns is not filtered.
explain_query is still treated as agent SQL for the one-statement rule and for provenance: the SQL it is given is parsed on its own and re-attached as an AST node rather than concatenated into a string.
Policy configuration reference
The file is a YAML mapping with one top-level key, databases, mapping an alias to a database policy.
At least one database is required.
Unknown keys are rejected, so a typo fails at startup instead of silently disabling a restriction.
Database options
Field | Type | Default | Meaning |
| string | required | libpq connection URI. Must not be empty. Use |
|
| inherits | Per-database override of the server's access mode. See Per-database access mode - in short, |
| integer > 0 |
|
|
| integer > 0 |
| Ceiling. A larger literal |
| boolean |
| Turns row limit injection and clamping off entirely - and, with it, the blanket refusal of cursors. |
| list of strings |
| Referencing a table in any other schema is rejected. |
| map of name to table policy |
| The table allowlist. An empty map allows nothing. Keys may be bare ( |
|
|
|
|
| list of strings | built-in list | Which catalogs survive |
| string or null |
| Salt for the |
Table options
Field | Type | Default | Meaning |
|
|
|
|
| list of strings |
| Must be non-empty when the mode is |
| list of strings |
| Columns every query level touching this table must constrain against values the query names. A guardrail against an unqualified sweep, not tenant isolation. |
| map of column to mask rule |
| Per-column PII masking of returned values. Column names are matched case-insensitively; two keys differing only by case are an error. |
A table written as {} is fully readable - listing it under tables is what makes it referenceable at all, and the fields above only narrow that grant.
A mask rule is strategy: plus that strategy's parameters, written either nested under params: or inline beside strategy: (the nested form wins on conflict).
Every rule is fully validated at startup: an unknown parameter, a bad type, an out-of-range value, an unsupported digest, an invalid regex or a missing salt stops the server rather than surfacing halfway through a query.
Once a table restricts its columns, a wildcard over it (SELECT *, t.*, a whole-row reference such as SELECT c FROM customers c) is refused rather than expanded: the policy layer has no catalog to expand it against, and a stale expansion would fail open.
A hidden column may not be selected, filtered on, joined on, grouped by or ordered by - a WHERE clause on it still leaks its values through which rows come back.
require_predicate_on in detail
The rule accepts only a predicate that pins the column to values the query itself names, and it must be reachable through AND only, at the same query level as the table reference.
Accepted: col = <literal>, col = $1, col IN (<literal>, <literal>), col = ANY (<array literal>) and col = ALL (<array literal>). A cast around the literal (col = '5'::int) is fine.
Not accepted, deliberately:
col IS NOT NULLandcol IS NULL- on aNOT NULLcolumn the first is every row of the table, so it bounds nothing;col IN (SELECT ...)andcol = ANY (SELECT ...)- the agent writes that subquery too, soIN (SELECT tenant_id FROM tenants)is the whole table again;a range such as
col > 5, and anything computed by the database (a function call, another column) on the non-literal side;a predicate inside
ORorNOT, one in an outer join'sONclause, and one in a different subquery or CTE.
An equijoin propagates the constraint, so a.tenant_id = b.tenant_id AND b.tenant_id = 5 satisfies the requirement for both tables.
Every arm of a set operation, every CTE body, every FROM-subquery and every occurrence in a self-join is checked separately - a predicate at one level cannot vouch for another.
This setting was called required_filters before release. There is no deprecated alias: the old key is refused at startup with an error that names the new one and explains the rename.
System catalogs in detail
Under deny, the built-in allowed_system_catalogs list covers exactly what this server's own tools need - pg_stat_statements, the pg_stat_user_* and pg_statio_user_* views, pg_stats, pg_indexes, the replication views, the structural catalogs used by the health checks, the extension-discovery catalogs, and information_schema.*.
Everything else in pg_catalog is unreachable, and a hard denylist (roles and passwords, other sessions' activity, raw statistics, foreign-server credentials, host-based auth rules) is never granted by a schema.* wildcard - only by naming the catalog exactly.
The full lists are in src/pgwarden_mcp/policy/catalogs.py.
Views and sequences are relations too, and the sequence health check reads sequence relations directly. See Health checks report their own failures for the naming-convention exemption that keeps that check working.
Reading pg_stats does not give access to the sample values it carries; see the rejection table.
Contradictions rejected at load time
Beyond the per-field validation above, seven combinations are refused when the file is read, because each one is a rule that would either never apply, make a table unqueryable, or read as a restriction while silently granting access. Each raises a startup error naming the table (or database), the column (or field) and the fix.
Config | Why it is rejected |
| The predicate is mandatory and referencing the column is forbidden, so every query on the table would be rejected. Add the column to an |
| A masked column may not be used in |
Per-table rules under the |
|
An unquoted | In YAML that is the absent value, not the |
|
|
| A relation is matched against |
|
|
The second one is enforced twice: once by the config model and again when the policy engine is constructed, so it holds even for a policy built in code rather than loaded from YAML.
Other startup errors in the same spirit: a schema-qualified table key whose schema is not in allowed_schemas, a default_row_limit greater than max_row_limit, an empty connection_url, two mapping keys that collide once ${VAR} references are expanded, and any reference to an environment variable that is not set.
Config that is warned about, not rejected
Two patterns are logged as warnings rather than refused, because each has a legitimate reading:
an
allowed_system_catalogslist written next tosystem_catalog_access: allow, which already grants everything, so the list has no effect;a bare (schema-unqualified) table key carrying rules while more than one schema is allowed - the rules, and the access they grant, then apply to a same-named table in any allowed schema, whose different column set an
excludelist would leave exposed.
Masking strategies
Masking is applied to the values of an output column after the rows come back, not by rewriting SQL.
A masked column may be selected on its own (SELECT email, SELECT c.email AS contact, SELECT * over a table with no column rules) but not wrapped in an expression, and it may not be used as a filter, join key, grouping, partitioning or ordering key, nor as an aggregate's FILTER (WHERE ...) condition - see the rejection table.
Strategy | Parameters (default) | Input | Output |
|
|
|
|
| none | anything |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hash accepts algorithm values blake2b, blake2s, sha256, sha384, sha512, sha3_256, sha3_384 and sha3_512 (intersected with what the interpreter actually offers). Variable-length digests and MD5/SHA-1 are not available. The digests shown here are illustrative - the real output depends on your salt.
Worked examples
Rule | Input | Output | Why |
|
|
| The middle is hidden one character per character. |
|
|
| Fewer than |
|
|
|
|
|
|
| Non-text scalars are stringified and masked as text, so a masked integer comes back as a string. |
|
|
| Structured and binary values are always fully redacted; partially masking a JSON dump would leak keys and can produce invalid JSON. |
|
|
| The local part is never shown in full. |
|
|
| Anything that is not a well-formed address - no |
|
|
| A value with no ASCII digits is not a phone number, so it is not passed through. Separators and punctuation are dropped, not preserved. |
|
|
| Equal inputs under the same salt always hash alike, so equal values stay equal in the result set and the client can still group or join on what it got back. The SQL cannot: |
any |
|
|
|
any except |
|
| The empty string carries no PII and stays distinguishable from |
Text is sliced by grapheme cluster, so a "show 2 characters" rule never cuts a combining accent or an emoji sequence in half and never reveals a third character.
Rejections you will hit, and how to rewrite
The policy layer has no catalog of your columns, so wherever it cannot resolve a reference with certainty it refuses rather than guessing. These refusals are the ones users hit in practice. Every message below is what the agent actually receives, abbreviated; each one already names the fix.
Rejected query | Error (abbreviated) | Correct rewrite |
|
| Name the columns: |
|
| Qualify it: |
|
| Qualify it: |
|
| Write a literal: |
|
|
|
|
| Run the |
|
| Select the column and compare on an unmasked one: |
|
| Drop the clause, or order by an unmasked column. Positional keys are caught the same way: |
|
| Order by an unmasked position - |
|
| Name the output columns - |
|
| Selecting a masked column through a CTE or a subquery is fine; filtering on it is not. Filter on an unmasked column. Renaming does not help: |
|
| An aggregate's |
|
| Same shape, written as a value. |
|
|
|
|
| Write the join condition out with |
|
| Group by an unmasked column. |
|
| Name the columns explicitly in every arm. Position, not name, is what lines set-operation arms up, and a |
|
| Select the masked column on its own. Any constant arm counts - a literal, a |
|
| Write the relation in lower case. The allowlist folds case but Postgres does not, so |
|
| Rewrite as a |
|
| Submit one statement at a time. This was the most serious hole found: psycopg runs every statement in the string and hands back only the last one's rows, so a |
|
| Name the summary columns instead: |
|
|
|
|
|
|
|
| Name the columns in the inner |
Masking still flows correctly through the constructs it can follow: subqueries, CTEs (including recursive ones and column alias lists over named columns), lateral joins, set operations whose arms line up, and * over a masked table are all masked, keyed by the output name the query gives the column.
It follows a rename, too - (SELECT email AS e FROM users) s masks s.e - which is what lets the predicate ban follow one as well.
Multiple databases
Each key under databases is an alias.
Every alias gets its own connection pool and its own policy engine; nothing is shared between them, including mask_salt.
Every pool is warmed at startup and otherwise created on first use; a database that is unreachable is logged and retried on the next call rather than preventing the server from starting.
list_databasesreturns each alias with its effectiveaccess_mode, apolicy_enforcedboolean,allowed_schemas, allowed tables,system_catalog_access,default_row_limitandmax_row_limit. It reads configuration only: it never connects to a database and never returns a connection URL.Every database-touching tool takes an optional
databaseparameter. With exactly one database configured it can be omitted. With several configured, omitting it is an error that names the valid aliases so the model can retry:This server is configured with 2 databases, so the 'database' parameter is required. Valid values: analytics, support. Call list_databases for details about each one.An unknown alias is reported the same way, and never with a connection URL attached.
databases:
analytics:
connection_url: "postgresql://${PGUSER}:${PGPASSWORD}@analytics.internal:5432/analytics"
tables:
daily_revenue: {}
support:
connection_url: "${SUPPORT_DATABASE_URI}"
tables:
tickets: {}Per-database access mode
Each database under databases can set its own access_mode, independent of the others:
databases:
dev:
connection_url: "${DEV_URI}"
access_mode: unrestricted # no policy rules permitted below
prod:
connection_url: "${PROD_URI}"
access_mode: restricted
allowed_schemas: [public]
tables:
customers:
mask:
email: {strategy: email}This lets one server serve a writable development database and a policy-guarded production database side by side.
access_mode: unrestricted means the database is completely unpoliced: no policy engine is built for it at all, it gets a plain read/write SQL driver, and none of the checks in What is enforced, and where run against it (subject to the CLI floor below). Because of that, an unrestricted database may not also carry any policy-only field - tables, allowed_schemas, default_row_limit, max_row_limit, enforce_row_limits, system_catalog_access, allowed_system_catalogs or mask_salt. Setting any of those alongside access_mode: unrestricted is a config-load error: the policy engine only understands SELECT, so a rule written for an unpoliced database would silently never apply to the INSERT / UPDATE / DELETE it actually allows, and the validator would rather stop the server at load time than let that fail open.
access_mode: restricted, or leaving the field unset entirely under a policy file, keeps the database policy-enforced exactly as described in the rest of this document. Unset is not the same as unrestricted: a database with no access_mode at all still gets the full policy engine even under the server's default --access-mode=unrestricted, because supplying --config selects the parsing driver regardless (see --config implies read-only). The only way to get an unpoliced database is to write access_mode: unrestricted explicitly.
The --access-mode CLI flag is a floor, never a ceiling. A per-database access_mode can only tighten it, never loosen it:
CLI | database | result |
|
| still read-only and policy-checked - the flag wins |
|
| policy-enforced, read-only |
|
| unpoliced, read/write |
| unset | policy-enforced, read-only (unchanged pre-existing behaviour) |
--access-mode=restricted therefore remains a reliable blanket kill switch: no policy file can hand write access back to a database once the flag says restricted.
list_databases reports the effective access_mode for each alias - after the floor above is applied - plus a policy_enforced boolean, alongside the fields it already returns.
Hot reload via SIGHUP
An operator can edit the policy file and send the running server process SIGHUP to reload it without a restart:
kill -HUP <pid>
# or, containerized:
docker kill -s HUP <container>There is deliberately no MCP tool for this. Changing an agent's own guardrails stays an operator action off the agent's clock - the agent being guarded gets no lever on the timing of its own constraints.
Reload is only wired up when the server was started with --config / PGWARDEN_CONFIG in the first place. A bare PGWARDEN_DATABASE_URI run has no policy file to reread - the SIGHUP handler is not even registered - so sending the signal does nothing.
Reload fails closed. The new file is fully read, ${VAR}-interpolated and validated - the same load-time checks a fresh startup runs - before anything about the running server changes. If the edit is broken (bad YAML, an unset ${VAR}, a validation error such as the access_mode: unrestricted contradiction above), the reload is aborted, logged as an error, and the previously running policy stays in effect, untouched. Nothing is ever applied partially.
Pools are reused when possible. A database whose connection_url did not change keeps its existing connection pool - no dropped connections, no reconnect. Only a database whose DSN changed gets a fresh pool, and its old one is closed only after the new one is in place. Databases added to or removed from the file between reloads are picked up and torn down the same way.
A query already holding a connection is not interrupted. It finishes under the policy it started with; the new policy takes effect starting with the next tool call. A tool call still queued for a connection slot when its database's DSN changes may instead see a transient connection error and should be retried - the DSN change itself means a new pool is now in place.
Security notes and limitations
Read this section before pointing the policy layer at anything that matters. It is a defense-in-depth layer at the SQL text level, and it is honest about what that cannot do.
It does not replace Postgres roles or RLS
The policy layer decides what SQL it will send. Postgres decides what that SQL is allowed to do. Only the second one is enforced by the database, survives a bug in this parser, and applies to every other client of the same database.
Run the MCP server as a dedicated, least-privilege Postgres role regardless of the policy file: GRANT SELECT on exactly the tables the policy allows, nothing else, and use row-level security for tenant isolation - which require_predicate_on does not provide, as the next section explains.
The policy layer then becomes what it is good at: a fast, agent-legible guardrail that produces actionable errors, sits in front of the database's own enforcement, and covers things SQL grants cannot express (row limits, PII masking, catalog denial).
Note the asymmetry in how the two kinds of failure behave:
A bug in the policy layer fails closed. Anything it cannot resolve with certainty - an unqualified column, an unbounded
LIMIT, a renamed wildcard, a cursor, apg_statsvalue column, a statement kind it does not model, a mixed-case relation name - is rejected rather than executed.A misconfiguration fails open.
columns.mode: allon a PII table with nomaskrules is a valid, silent, wide-open policy. So istables: {"*": {}}, and so issystem_catalog_access: allow. Nothing warns you. (A rule that would never apply, or would make a table unqueryable, is rejected - see Contradictions rejected at load time - but a policy that simply restricts nothing is valid.) Review policy files the way you reviewGRANTstatements.
require_predicate_on is a guardrail, not tenant isolation
This is the single most important thing to get right about the policy file.
require_predicate_on: [tenant_id] forces every query level that touches the table to constrain tenant_id against values the query itself names.
It checks the shape of the predicate and nothing else.
It never sees, and never could see, which tenant is asking:
-- Rejected: no predicate on tenant_id at all.
SELECT id, total FROM orders;
-- Accepted.
SELECT id, total FROM orders WHERE tenant_id = 42;
-- Also accepted. The policy layer has no idea 99 is somebody else's tenant.
SELECT id, total FROM orders WHERE tenant_id = 99;
-- Also accepted, and returns every tenant the agent cares to list.
SELECT id, total FROM orders WHERE tenant_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9);So what it buys you is real but narrow: an agent cannot read the table whole by forgetting a WHERE clause, and the error it gets back names the column it has to constrain.
That stops the ordinary accident. It stops nothing that is trying.
Per-tenant isolation has to be enforced somewhere that knows who the caller is. Two places do:
Postgres row-level security, with the tenant established per session (
SET LOCAL app.tenant_id = ...inside the transaction, and a policy readingcurrent_setting('app.tenant_id')). The database then filters the rows, whatever SQL arrives.A server-injected predicate - your own code rewriting the query to add
AND tenant_id = <the caller's tenant>before it is sent.
This layer does neither. It does not know the caller's tenant, it never rewrites a predicate onto a query (the only rewrite it performs is LIMIT), and there is no configuration that makes it do either.
Deploying require_predicate_on as if it were tenant isolation gives you a multi-tenant database that any agent can read across, with a config file that reads as though it could not.
(The setting was called required_filters before release. It was renamed precisely because "required filters" reads as a guarantee of which rows come back. The old key is refused at startup with a message pointing at the new one; there is no deprecated alias.)
A masked column may be returned, but not probed
A mask is applied to the cells of an output column, after the rows come back. That alone would leave the column open as an oracle: a predicate over it answers a guess through which rows come back, and an ordering exposes the true order, both without ever defeating the mask. So masking carries the same predicate ban that column exclusion does:
-- Allowed. Returning the de-identified value is the point of masking.
SELECT id, email FROM contacts;
-- Rejected. The row that comes back would confirm the address.
SELECT id FROM contacts WHERE email = 'ceo@acme.com';
-- Rejected. Sorting happens on the real value, before the mask runs.
SELECT id, email FROM contacts ORDER BY email;A masked column is refused in WHERE, HAVING, JOIN ... ON (including the implicit ON of JOIN ... USING and of NATURAL JOIN), GROUP BY (including GROUPING SETS / CUBE / ROLLUP), ORDER BY, DISTINCT ON, a window's PARTITION BY / ORDER BY, an aggregate's FILTER (WHERE ...), and inside a sublink - everywhere an excluded column is refused, and allowed in exactly the one place an excluded column is not: the target list.
The check also follows the ways a name can be smuggled into one of those positions: an output alias (SELECT email AS contact ... ORDER BY contact), a target-list position (ORDER BY 1), an unqualified name that might be the masked column when several relations are in scope, and a masked column re-exported from a CTE or a FROM-subquery - under its own name or under a new one.
The column checks and the mask planner resolve against the same provenance machinery, so all of these are refused:
WITH c AS (SELECT email AS e FROM users) SELECT * FROM c WHERE c.e = 'x';
WITH c AS (SELECT email AS e FROM users) SELECT e FROM c ORDER BY e;
WITH c(e) AS (SELECT email FROM users) SELECT e FROM c WHERE e = 'x';
SELECT s.e FROM (SELECT email AS e FROM users) s WHERE s.e = 'x';
WITH a AS (SELECT email AS e FROM users), b AS (SELECT e AS f FROM a) SELECT f FROM b WHERE f = 'x';Selecting the renamed column is still fine (WITH c AS (SELECT email AS e FROM users) SELECT e FROM c runs, masked); it is comparing and ordering that are refused.
Positional keys over a set operation are resolved the same way. A UNION / INTERSECT / EXCEPT node has no target list of its own - its arms do - so a position is resolved against the merged shape the mask plan itself is built from, which takes output names from the leftmost arm (as Postgres does) and marks a position masked if either arm masks it. All of these are refused:
SELECT id, email FROM users UNION ALL SELECT id, email FROM contacts ORDER BY 2;
SELECT id, email FROM users EXCEPT SELECT id, email FROM contacts ORDER BY 2;
-- masked in one arm only ('orders.status' is not masked), and nested arbitrarily deep
SELECT id, status FROM orders UNION ALL SELECT id, email FROM users ORDER BY 2;
(SELECT id, email FROM users UNION ALL SELECT id, email FROM contacts) UNION ALL SELECT id, status FROM orders ORDER BY 2;
-- the merged column's name comes from the leftmost arm, so the alias is refused too
SELECT id, email AS e FROM users UNION ALL SELECT id, email FROM contacts ORDER BY e;ORDER BY 1 on the same queries still runs: the ban is per output position, not per query. GROUP BY and DISTINCT ON never had this gap - Postgres' grammar attaches both to a leaf SELECT, never to the set-operation node, so they were already checked against the arm's own target list.
A position behind a * is refused, because it cannot be resolved at all. SELECT * FROM users ORDER BY 2 sorts on whatever the second column of users turns out to be; this layer has no catalog, so it can tell that the wildcard covers a masked column but not which position that column lands in. Guessing would fail open, so any ORDER BY <n> / GROUP BY <n> over a wildcard that expands over a masking table is refused - including ORDER BY 1, and including the wildcard reaching the position through a subquery, a column alias list, a CTE or a TABLE users UNION ALL TABLE contacts. Name the output columns and the position resolves normally. A wildcard over a table with no mask rules is unaffected.
Two gaps are known and not caught:
A comparison written in the target list is caught by a different rule.
SELECT (email = 'x') AS hit FROM usersis an oracle with no predicate in it. The statement-level check does not see it, because a target list is not a filtering position; it is refused instead as an expression over a masked column, and that rule runs on agent SQL only (execute_sql, and the agent textexplain_queryembeds). Every agent statement goes through one of those, so in practice it is covered - but the two rules do not overlap, and a future caller that skipped the masking pass would not get this one.SELECT DISTINCT email FROM usersruns.DISTINCTover the target list is not a filtering position and is not banned, so the query returns one masked row per distinct real value: the column's distinct cardinality leaks, and under ahashmask so do its equivalence classes. If that matters, exclude the column rather than masking it.
If a column must not be readable at all, exclude it (columns.mode: exclude) rather than masking it. Exclusion refuses the column in the target list too, and takes SELECT * on the table with it.
Masking is for columns an agent legitimately needs to see in a de-identified form.
Either way, tenant isolation and column-level confidentiality belong in Postgres grants and RLS; this layer is the guardrail in front of them.
hash is a pseudonym, not a secret
hash is an HMAC of the value under mask_salt, truncated to length hex characters (default 12, which is 48 bits).
What it gives you is linkability, not confidentiality. Equal inputs hash alike, so a client can still group, count and join on the values it got back without ever seeing a real one - that is the whole point, and it is genuinely useful. What it does not give you is a guarantee that the real value cannot be recovered, and that limitation is inherent to a deterministic function over a guessable domain:
An agent that can get chosen values hashed under your salt can build a rainbow table offline. It does not need the salt itself, only the ability to submit a value and see its digest. It then hashes a plausible keyspace - every phone number in a region, every date of birth, every national ID format, the customer list it already has - and reverses the real hashes it was shown. Nothing about the mask is defeated directly; the digests simply match.
The easy path to that is closed. A set operation that puts a masked column and a caller-written constant in the same output position -
SELECT phone FROM users WHERE false UNION ALL SELECT '555-0100'- would have masked the constant, handing back the deployment's own hash of a value the caller picked, one guess per query and as many queries as it liked. That is now refused, inUNION,INTERSECTandEXCEPTalike. Closing that path does not change the underlying property, though: any channel that ever hashes an attacker-chosen value under the same salt reopens it, and this layer cannot promise there is no such channel.Low-cardinality columns are reversible even without a chosen-plaintext channel, by anyone who learns the salt. Truncation is not what makes that possible; a full-length digest of a small keyspace is just as reversible. Truncation does add collisions on top (expect them around ~16M distinct values at 48 bits).
Raise
lengthfor large domains. The maximum is 64 hex characters, the minimum 8.length: 32costs nothing but output width and removes the collision concern; it does not remove the reversibility one.The salt is mandatory and there is no default, precisely so that this strategy cannot silently degrade into a reversible no-op. It must be at least 8 characters; below 16 the server logs a warning.
Keep the salt secret and out of the config file (
${PGWARDEN_MASK_SALT}), give each database its own if hashed values must not be correlated across them, and remember that rotating it changes every hashed value.
Use hash when the agent needs to correlate rows without reading values, and the domain is large enough that guessing it is impractical.
For anything that must stay confidential - a national ID, a phone number, a card number, any column with a small or enumerable keyspace - use redact or null, which return no function of the real value at all.
Treat hash output as pseudonymized data (a persistent, stable identifier for a person), never as anonymized data.
email preserves the domain by design
ajay.ranwa@gokiwi.in becomes aj********@gokiwi.in.
The domain is kept because it is usually not the sensitive part and is genuinely useful for analysis (per-tenant, per-provider breakdowns).
A rare or personal domain can still identify an individual.
Use redact, null or hash for columns where the domain matters.
regex passes non-matching values through unchanged
A regex rule replaces matches.
A value with no match is returned raw.
That is correct for scrubbing a pattern out of free text and dangerous if you assumed the rule was total:
# Scrubs account numbers out of free text. Anything else is returned as written.
body:
strategy: regex
params:
pattern: "ACC-[0-9]{6,}"
replacement: "ACC-******"If a column must never be emitted raw, anchor a catch-all - pattern: "^.*$" with flags: "s" so that . also matches newlines - or use redact, which is what a total regex amounts to.
Two further notes: replacement supports backreferences, so do not capture the part you meant to hide; and because Python's re has no timeout, values longer than max_input_length (4096 by default) are fully redacted instead of matched, which keeps a catastrophically backtracking pattern from stalling the server on one hostile row.
Row limits cap a response, not total exfiltration
default_row_limit and max_row_limit bound a single result set.
They do not bound what an agent can accumulate:
OFFSETpaging walks a whole table 500 rows at a time.Several small queries add up to one large one, and nothing correlates them.
Treat row limits as a context-window and cost guard, not as a data-loss control.
Two holes that are closed:
DECLARE ... CURSORused to walk past the limit entirely, because the injectedLIMITonly ever lands on aSELECTand the rows arrive through a laterFETCH ALLthat this layer never sees. Declaring a cursor is now refused outright whileenforce_row_limitsis on, over any table. Setenforce_row_limits: falseif an agent genuinely needs cursors - at which point nothing bounds a result set, except that a cursor over a masked column is still refused, because masking cannot follow rows into a laterFETCH.A second statement after a
;. psycopg executes every statement in the string it is given and returns only the last one's rows, soSELECT 1; SELECT email FROM customersused to run a query that neither the injectedLIMITnor the mask plan applied to - the limit and the plan were computed for the statement the caller thought it was sending. Agent SQL is now held to exactly one statement, and the text is parsed and re-attached as an AST node rather than concatenated into a larger query. (Server-authored SQL may still be multi-statement; the one legitimate case is the HypoPG prologue thatexplain_queryneeds, which is recognised structurally rather than by a flag a future caller could reach for.)
Some things remain readable that you might not expect
pg_statsis allowed by default, but its sample values are not. The view'smost_common_vals,most_common_elems,histogram_boundsandelem_count_histogramcolumns hold values copied verbatim out of the tables they describe - including out of columns you excluded or masked, since the policy's column rules apply tocustomers, not to a catalog view aboutcustomers. Those four columns are therefore refused onpg_stats,pg_stats_extandpg_stats_ext_exprswhenever any table in the policy configurescolumnsormask, and so is aSELECT *that would expand over them. TheWHERE tablename = '...'predicate is deliberately not consulted: it is agent-controlled and may be absent, negated orOR-ed, so it can never prove which table the rows describe. The summary columns (n_distinct,null_frac,avg_width,correlation,schemaname,tablename,attname, ...) stay readable, which is what the health checks and the index advisor read. One server-side feature does lose something: when a query frompg_stat_statementsstill has$1placeholders, the parameter substitution used byexplain_queryand the index advisor tries to pick a realistic value out ofmost_common_vals/histogram_bounds; under a restrictive policy that lookup is refused, logged as a warning, and generic placeholder values are used instead. A policy where no table restricts or masks anything leaves the view untouched. (pg_statistic, the underlying table, is denied outright.)information_schemais fully readable by default (information_schema.*), so the existence and shape of tables outside the allowlist is discoverable even though their contents are not.EXPLAINoutput is not masked or limited, as described in What is enforced, and where.Postgres still filters
pg_statsby what the connecting role may read, which is another reason to run as a least-privilege role.
Health checks report their own failures, one line at a time
analyze_db_health runs each check independently.
A check that raises - because the policy denied a relation it needs, or for any other reason - reports on its own line and every other check still contributes its normal output:
...
Connection health: Check failed - PolicyViolation: Access to system catalog 'pg_catalog.pg_stat_activity' is denied ...
...
Health check summary: 1 of 11 checks failed (Connection health). All other results above are valid.The failing line is prefixed Check failed - <ExceptionType>: <message>, and the trailing summary names every check that failed.
That matters for two checks in particular:
connectionreadspg_stat_activity, which is on the hard denylist because it exposes every other session's user, database and current SQL text. Under a default policy that one check fails and the rest of the report is unaffected. Opt in with one line, accepting that agents can then see other sessions:allowed_system_catalogs: # ... the rest of the built-in list, which this key replaces ... - pg_stat_activityOr scope the call and skip it:
analyze_db_health(health_type="index,vacuum,buffer,constraint,replication").sequencereads each sequence relation directly, and a sequence is never written undertables:. The policy engine therefore permits a sequence named by Postgres'sserial/ identity convention -<allowlisted_table>_<column>_seq, in a schema the policy allows - without it being listed:users_id_seqanduser_accounts_external_id_seqresolve as long asusersanduser_accountsare allowlisted. Anything outside that convention (a hand-createdCREATE SEQUENCE order_numbers, a renamed sequence, one owned by a table that is not allowlisted) is still refused - as is a sequence whoseSELECTgrant Postgres itself withholds. The check skips those and accounts for them in a trailing line (N sequences could not be read and were skipped, so their usage is unknown: ...) rather than failing or, worse, reporting every sequence as healthy. List such sequences undertables:if the check needs to cover them. The exemption is name-based, not catalog-based: an ordinary table called<allowlisted-table>_<something>_seqbecomes readable too.
If every check fails the summary says so explicitly (all N checks failed ... No health data could be collected.), so an empty-looking report is never mistaken for a clean bill of health.
Configuration traps
A bare table key matches that table name in any allowed schema.
tables: {orders: {}}withallowed_schemas: [public, reporting]allows bothpublic.ordersandreporting.orders. Write the key qualified when you mean one of them.allowed_system_catalogsreplaces the built-in list, it does not extend it. A three-entry list here disables most of the server's own tooling.A
require_predicate_oncolumn must be both readable and unmasked. Hiding it withcolumns, or masking it, would make the table unqueryable - the predicate is mandatory and referencing the column is forbidden - so both combinations are rejected at load time rather than surfacing as a per-query error. So is a per-table rule written under the"*"key.require_predicate_onis not tenant isolation, whatever the column is called. It checks that a predicate exists, not what it says, soWHERE tenant_id = <any tenant>satisfies it. Use RLS. This is the mistake most likely to turn a policy file into a false sense of safety.required_filtersis not a valid key. It was the pre-release name ofrequire_predicate_onand is refused at startup with a message naming the replacement. There is no deprecated alias, and a config carrying both keys is still an error.--configimplies read-only. A policy can only be enforced by parsing the SQL, so supplying a config file selects the parsing driver even under--access-mode=unrestricted.execute_sqlwill then reject DDL and DML - and the policy engine independently refuses any statement kind it does not model, so DML,COPY,PREPAREand DDL are rejected twice over. This is deliberate; it is also a surprise if you expectedunrestrictedto still write.Every renamed environment variable keeps its old name working, including
POSTGRES_MCP_MASK_SALT.PGWARDEN_MASK_SALT,PGWARDEN_CONFIG,PGWARDEN_DATABASE_URIandPGWARDEN_INCLUDE_LANGFUSE_TRACEall fall back to their pre-rename spelling and log a warning naming the replacement. The fallback is a migration aid, not a supported configuration: a future release drops it, so rename the variables rather than relying on it. Note the fallback applies to the variables the server reads directly - a${POSTGRES_MCP_MASK_SALT}written inside a policy file is an ordinary${VAR}interpolation and is read literally, with no aliasing.A SIGHUP reload that changes a database's DSN can surface a transient connection error to a call still queued for that database's old pool. Only a query that has already been handed a connection is guaranteed to finish under the policy it started with; one still waiting for a free slot when the old pool closes should be retried against the now-current one.
Inherited capabilities
Everything in this section comes from crystaldba/postgres-mcp and works unchanged under a policy - subject to the checks in What is enforced, and where, and never row-limited or masked, because these tools run server-authored SQL.
MCP tool reference
The server exposes functionality through MCP tools only, not resources, because tool support is far more widespread across MCP clients.
Tool | Description |
| Lists the databases this server is configured to query, with the schemas, tables, catalog access and row limits each policy allows. Reads configuration only: it never connects to a database and never returns credentials. |
| Lists all schemas in the instance. |
| Lists database objects (tables, views, sequences, extensions) within a schema. |
| Describes one object - a table's columns, constraints and indexes, for example. |
| Runs a SQL statement. The only tool through which agent SQL reaches the database as rows, so row limits and masking apply here. |
| Returns the execution plan for a query, optionally with |
| Reports the slowest or most resource-intensive queries from |
| Analyzes the workload to find resource-intensive queries, then recommends indexes. |
| Recommends indexes for a list of up to 10 supplied queries. |
| Runs health checks. |
Every tool that touches a database also accepts an optional database parameter naming the configured alias to use. It can be omitted when the server has a single database; with several configured it is required, and list_databases reports the valid values.
Access modes
--access-mode controls what kind of statement may run:
unrestricted(the default) allows full read/write access to data and schema. Suitable for development.restrictedlimits operations to read-only transactions and caps query execution time at 30 seconds. Suitable for production.
Read-only enforcement is not just a flag: because Postgres has no session-level read-only mode, the statement is parsed with pglast before execution and anything that could escape the read-only transaction - notably COMMIT and ROLLBACK, as in ROLLBACK; DROP TABLE users - is rejected. (If you have enabled unsafe stored-procedure languages on the database, those protections can be circumvented from inside a function; PL/pgSQL and PL/Python cannot issue COMMIT or ROLLBACK.)
Access modes address integrity: what an agent may change.
The policy layer addresses confidentiality and availability on top of them - which tables and columns an agent may read, how many rows it may pull back, and whether PII is de-identified on the way out.
Supplying --config selects the same parsing driver that restricted mode uses, so a policed server is read-only in either access mode.
This flag is a server-wide default, not a per-database setting - a policy file can tighten it further for individual databases. See Per-database access mode.
Postgres extension setup
Index tuning and the full performance analysis need two extensions:
pg_stat_statementsrecords the runtime and resource consumption of each query, which is how the server finds tuning targets.hypopgsimulates the planner's behaviour after adding an index, without building it.
On AWS RDS, Azure Database for PostgreSQL and Google Cloud SQL both are usually available already:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS hypopg;On self-managed Postgres, pg_stat_statements must additionally be listed in shared_preload_libraries, and hypopg may need installing at the system level because it does not always ship with Postgres.
Both catalogs are in the built-in allowed_system_catalogs list, so a deny policy does not get in the way.
Index tuning
analyze_workload_indexes and analyze_query_indexes implement an adaptation of Microsoft's Anytime Algorithm for index selection. It runs in four stages:
Identify queries worth tuning. Either you supply them, or the workload is analyzed through
pg_stat_statementsusing mean execution time with thresholds on call count and mean time. Queries are normalized so that everything from one template counts once - a limited form of workload compression - and then weighted equally.Generate candidate indexes. The SQL is parsed and every column used in a filter, join, grouping or sort becomes a candidate, including multicolumn combinations. Only one permutation of each multicolumn candidate is considered (chosen at random), because permutations usually perform equivalently and the search space is otherwise unmanageable.
Search for the best configuration.
hypopgprovides "what if?" cost estimates from the real Postgres cost model. A greedy search finds the best one-index solution, then the best index to add to it, and so on, terminating when the time budget is exhausted or a round produces no gain above a 10% minimum improvement threshold. Because normalization strips out parameter constants, realistic values are sampled from table statistics to produce plannable queries (Postgres 16's generic plans have limitations here, for example aroundLIKE).Cost-benefit analysis. Rather than optimizing purely against a storage budget, the search selects a point on the Pareto front using relative changes: by default the log10 performance improvement must be at least 2x the log10 space cost, which works out to allowing a 10x space increase for a 100x speedup.
Compared to Dexter, this searches a larger space with different heuristics - better solutions, longer runtime. The output shows the work done in each round, including before/after query plans, which gives the calling LLM context for its recommendations.
Experimental: index tuning by LLM. Passing method="llm" swaps the heuristic search for Optimization by LLM: the schema and query plans are given to an LLM, which proposes index configurations that are then scored with hypopg and fed back for another round, until no further improvement appears. It can help when the search space is large or many-column indexes matter. It requires OPENAI_API_KEY to be set.
Database health checks
analyze_db_health adapts the checks from PgHero:
Index health. Unused, duplicate, invalid and bloated indexes. Autovacuum marks index entries for dead tuples reusable but does not compact index pages, so pages accumulate few live references over time.
Buffer cache hit rate. The proportion of reads served from the buffer cache rather than disk, for both tables and indexes.
Connection health. Connection count and utilization - running out of connections is the acute risk, but a high idle or blocked count is a signal too. Reads
pg_stat_activity, which is denied by default; see above.Vacuum health. Tables approaching transaction ID wraparound. Postgres uses 32-bit transaction IDs and must "freeze" old rows before those IDs are reused; a database that falls behind stops accepting writes.
Replication health. Lag between primary and replicas, replication status, and replication slot usage.
Constraint health. Invalid constraints, which can appear after a bulk load or a recovery.
Sequence health. Sequences at risk of exceeding their maximum value.
Query plans and hypothetical indexes
explain_query returns the planner's execution plan and cost estimates. With analyze=True it runs the query for real statistics; with hypothetical_indexes it uses hypopg to show what the plan would be after adding indexes that were never built (the two options cannot be combined).
[
{"table": "users", "columns": ["email"], "using": "btree"},
{"table": "orders", "columns": ["user_id", "created_at"]}
]Example prompts
Check the health of my database and identify any issues.
What are the slowest queries in my database? And how can I speed them up?
Analyze my database workload and suggest indexes to improve performance.
Transports
--transport selects stdio (the default), sse or streamable-http. The HTTP transports let several MCP clients share one server, possibly a remote one, and bind to localhost:8000 by default (--sse-host / --sse-port, --streamable-http-host / --streamable-http-port).
docker run -p 8000:8000 \
-v /etc/pgwarden:/etc/pgwarden:ro \
-e PGUSER -e PGPASSWORD -e PGWARDEN_MASK_SALT \
pgwarden-mcp --config /etc/pgwarden/policy.yaml --transport=sse --sse-host=0.0.0.0Client configuration for SSE, in Cursor's mcp.json or Cline's cline_mcp_settings.json:
{
"mcpServers": {
"pgwarden": {
"type": "sse",
"url": "http://localhost:8000/sse"
}
}
}Windsurf's mcp_config.json uses serverUrl instead of url.
Development
git clone https://github.com/gokiwitech/pgwarden-mcp.git pgwarden-mcp
cd pgwarden-mcp
uv syncRun the server against a database with no policy:
uv run pgwarden-mcp "postgresql://user:password@localhost:5432/dbname"The checks CI runs, in order:
uv run ruff format --check .
uv run ruff check .
uv run pyright
uv run pytest -v --log-cli-level=INFOThe policy layer's tests are in tests/unit/test_policy_*.py, tests/unit/test_masking*.py, tests/unit/test_agent_sql_invariant.py and tests/integration/test_policy_guardrails_integration.py. The integration suite needs Docker.
Source layout for the policy layer:
Path | Contents |
The config schema. Every YAML field and every load-time validation. | |
File reading, post-parse | |
The parse-tree checks, the row-limit rewrite and the mask planner. | |
The masking strategies and their parameter validation. | |
System catalog allowlist, denylist and the statistics-view column rules. | |
Per-alias connection pools and policy engines. |
License and credits
MIT. See LICENSE.
This is a hard fork of crystaldba/postgres-mcp, copyright Crystal Corp., originally authored by Johann Schleier-Smith - upstream's copyright notice is retained in full. Upstream's database health checks are adapted from PgHero by Andrew Kane. The index advisor follows Microsoft's Anytime Algorithm of Database Tuning Advisor for Microsoft SQL Server, and the experimental LLM tuner follows Optimization by LLM.
Available Tools
10 toolsanalyze_db_healthARead-only
Analyzes database health. Here are the available health checks:
index - checks for invalid, duplicate, and bloated indexes
connection - checks the number of connection and their utilization
vacuum - checks vacuum health for transaction id wraparound
sequence - checks sequences at risk of exceeding their maximum value
replication - checks replication health including lag and slots
buffer - checks for buffer cache hit rates for indexes and tables
constraint - checks for invalid constraints
all - runs all checks You can optionally specify a single health check or a comma-separated list of health checks. The default is 'all' checks.
| Name | Required | Description | Default |
|---|---|---|---|
| database | No | Optional. Alias of the configured database to query. Omit it when the server has only one database configured. When several are configured this parameter is required - call list_databases first to learn the valid aliases. | |
| health_type | No | Optional. Valid values are: all, buffer, connection, constraint, index, replication, sequence, vacuum. | all |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations only mark readOnlyHint as true, so the description adds meaningful behavioral context by detailing exactly what each check covers and clarifying that 'all' runs everything. It does not mention performance costs or guardrails, but the read-only annotation plus the enumerated checks cover the most important behavioral expectations.
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?
The description is front-loaded with the core purpose and then uses a clear bulleted list for the health check options. It is slightly longer than strictly necessary, but every sentence contributes meaningful detail and the structure aids scannability.
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 tool with two optional parameters, full schema coverage, and an output schema, the description provides sufficient context to invoke it correctly. It clarifies the default, the accepted health check values, and the behavior of 'all', leaving no major gap for an agent.
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%, but the description adds value by explaining what each health_type value actually checks (e.g., 'connection - checks the number of connection and their utilization'). This goes beyond the schema's bare enum list and helps an agent choose the right parameter value.
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 ('Analyzes database health') and then itemizes all health check categories. This lets an agent distinguish it from sibling tools like analyze_workload_indexes and execute_sql without inspecting schemas.
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 explains how to narrow the scope using health_type and the default 'all', but it does not explicitly compare this tool with alternatives or state when to prefer it over analyze_workload_indexes or analyze_query_indexes. The usage context is implied by the health-check list rather than directly specified.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
analyze_query_indexesARead-only
Analyze a list of (up to 10) SQL queries and recommend optimal indexes
| Name | Required | Description | Default |
|---|---|---|---|
| method | No | Method to use for analysis | dta |
| queries | Yes | List of Query strings to analyze | |
| database | No | Optional. Alias of the configured database to query. Omit it when the server has only one database configured. When several are configured this parameter is required - call list_databases first to learn the valid aliases. | |
| max_index_size_mb | No | Max index size in MB |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations provide readOnlyHint=true, so the description only needs to add context beyond that. It mentions 'recommend optimal indexes' which aligns with a read-only analysis. However, it does not disclose that the 'method' parameter (dta vs llm) may involve different costs, external calls, or result variations, nor any rate limits. The description adds minimal behavioral detail beyond the annotation.
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, well-formed sentence that is front-loaded with the core action and scope. Every word earns its place; there is no redundancy or filler.
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?
Given an output schema exists (as indicated by context signals) and the input schema covers all parameters, the description need not explain return values. It adequately states the purpose and the batch limit. However, it does not clarify the significance of the 'method' parameter (dta vs llm) or mention potential long execution times for analysis, which could be relevant for an agent deciding to invoke it. These are minor gaps given the schema already documents the parameters.
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 baseline is 3. The description adds the 'up to 10' constraint on the 'queries' array, which is not present in the schema (schema only says array of strings). This is valuable semantic information that helps agents understand the limit. It does not explain 'method' or 'database' beyond what the schema provides, but that is acceptable given full schema coverage.
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 states a clear verb ('Analyze') and resource ('a list of SQL queries') with an outcome ('recommend optimal indexes'). It is specific about the input scope (up to 10 queries), which helps differentiate from the sibling 'analyze_workload_indexes' that likely targets a workload. However, it does not explicitly name that sibling or explain the difference, so it loses a point.
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?
No guidance is given on when to use this tool versus alternatives like 'analyze_workload_indexes' or 'explain_query'. The description implies use for analyzing a batch of queries but does not state exclusions or conditions, leaving the agent to infer. This is a clear gap for a tool with several analytical siblings.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
analyze_workload_indexesBRead-only
Analyze frequently executed queries in the database and recommend optimal indexes
| Name | Required | Description | Default |
|---|---|---|---|
| method | No | Method to use for analysis | dta |
| database | No | Optional. Alias of the configured database to query. Omit it when the server has only one database configured. When several are configured this parameter is required - call list_databases first to learn the valid aliases. | |
| max_index_size_mb | No | Max index size in MB |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description is consistent with the readOnlyHint annotation and adds the non-obvious nuance that the tool 'recommends' indexes rather than applying them. However, it does not disclose potential runtime costs, permission requirements, or analysis scope beyond what the annotation already conveys.
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?
The description is a single, efficient sentence with no filler. It front-loads the core action ('Analyze frequently executed queries') and includes the key output ('recommend optimal indexes') without redundant phrasing.
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 straightforward with an output schema and read-only annotation, but the description leaves important context unexplained: the difference between 'dta' and 'llm' methods, and how this differs from the similar sibling 'analyze_query_indexes'. This makes the definition minimally adequate but not fully self-sufficient for correct tool selection.
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 all three parameters are documented in the schema. The description adds no extra meaning about parameter semantics, such as what 'dta' versus 'llm' methods imply or how max_index_size_mb affects recommendations.
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 states a specific action ('Analyze frequently executed queries') and outcome ('recommend optimal indexes'), clearly identifying it as a workload-level index analysis tool. It does not explicitly distinguish itself from the sibling 'analyze_query_indexes', but the phrase 'frequently executed queries' implies a broader scope than a single-query tool.
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?
No guidance is given on when to use this tool versus siblings such as 'analyze_query_indexes' or 'get_top_queries'. The description implies general index recommendation but does not state prerequisites, exclusions, or a decision procedure for selecting this tool.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
execute_sqlCDestructive
Execute any SQL query
| Name | Required | Description | Default |
|---|---|---|---|
| sql | No | SQL to run | all |
| database | No | Optional. Alias of the configured database to query. Omit it when the server has only one database configured. When several are configured this parameter is required - call list_databases first to learn the valid aliases. |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The annotation destructiveHint=true already signals that this tool can be destructive. The description adds no further behavioral context, such as whether arbitrary DDL/DML is supported, what changes are irreversible, or what permissions are needed. It does not contradict the annotation, but it does not enrich it either.
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?
The description is a single short sentence, which is front-loaded and not verbose. However, it is overly generic and lacks useful detail, making it under-specified rather than appropriately concise. It does not waste words, but it also does not earn its place with substantive 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?
With an output schema and annotations present, the description does not need to explain return values or destructive risk. However, given that this is an arbitrary SQL execution tool with significant power, additional context about supported SQL dialects or constraints would be beneficial. The current description is minimally adequate but not comprehensive.
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 detailed descriptions for both `sql` and `database` parameters, including the optionality and alias behavior. The description 'Execute any SQL query' adds no parameter-specific meaning, so the 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?
The description states a clear verb and object: 'Execute' a 'SQL query'. However, the qualifier 'any' is vague and doesn't clarify whether this is for reads, writes, or both, nor does it explicitly differentiate from sibling tools like explain_query or analyze_query_indexes, which also involve SQL-related operations.
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 provides no guidance on when to use this tool versus alternatives, no prerequisites, and no warnings about destructive effects. While the schema mentions calling list_databases first when multiple databases are configured, the description itself omits this entirely, leaving usage entirely to inference.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
explain_queryBRead-only
Explains the execution plan for a SQL query, showing how the database will execute it and provides detailed cost estimates.
| Name | Required | Description | Default |
|---|---|---|---|
| sql | Yes | SQL query to explain | |
| analyze | No | When True, actually runs the query to show real execution statistics instead of estimates. Takes longer but provides more accurate information. | |
| database | No | Optional. Alias of the configured database to query. Omit it when the server has only one database configured. When several are configured this parameter is required - call list_databases first to learn the valid aliases. | |
| hypothetical_indexes | No | A list of hypothetical indexes to simulate. Each index must be a dictionary with these keys: - 'table': The table name to add the index to (e.g., 'users') - 'columns': List of column names to include in the index (e.g., ['email'] or ['last_name', 'first_name']) - 'using': Optional index method (default: 'btree', other options include 'hash', 'gist', etc.) Examples: [ {"table": "users", "columns": ["email"], "using": "btree"}, {"table": "orders", "columns": ["user_id", "created_at"]} ] If there is no hypothetical index, you can pass an empty list. |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations declare readOnlyHint=true, and the description adds that it shows the execution plan and cost estimates, which is output information. However, it does not disclose that the 'analyze' parameter actually runs the query, which could have side effects if the query is a write. No contradiction with annotations, but the description fails to add behavioral context beyond the annotation.
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?
One clear sentence, no filler, front-loaded with the core action. It is perfectly concise and structured.
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 relatively simple, and the schema fully documents all parameters, including the analyze behavior and database selection. However, the description does not mention that the tool can simulate hypothetical indexes or that analyze actually runs the query. While the schema covers these, the description alone lacks guidance on when to use it, which is a gap for contextual completeness.
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?
All parameters have schema descriptions covering 100% of the parameters. The description adds no parameter-specific information, relying entirely on the schema, which is adequate. 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 'explains' with the resource 'SQL query', clearly indicating it shows the execution plan and cost estimates. It distinguishes from execute_sql which runs the query, but does not explicitly name any sibling, so it misses full differentiation.
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?
No guidance on when to use this tool vs alternatives like execute_sql or analyze_query_indexes. The description does not mention when to use this over others, nor when not to use it, leaving the agent to infer based on the verb.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_object_detailsBRead-only
Show detailed information about a database object
| Name | Required | Description | Default |
|---|---|---|---|
| database | No | Optional. Alias of the configured database to query. Omit it when the server has only one database configured. When several are configured this parameter is required - call list_databases first to learn the valid aliases. | |
| object_name | Yes | Object name | |
| object_type | No | Object type: 'table', 'view', 'sequence', or 'extension' | table |
| schema_name | Yes | Schema name |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The readOnlyHint annotation already declares this is a read-only operation, and the description's 'Show' is consistent with that. However, the description adds no additional behavioral context (e.g., error behavior, pagination, or output scope), so it provides no value beyond the annotation. No contradiction exists.
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?
The description is a single concise sentence with no wasted words, and the main action is front-loaded. However, it is so terse that it sacrifices informative content, making it slightly less effective than a similarly concise description that adds scope or alternatives.
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?
Given the output schema and the fully described input schema, the description is sufficient to understand the basic operation, but it lacks context for when to choose this tool over siblings. An agent would need to infer from the tool name and schema that this returns details for a specific object, making the description minimally adequate but not rich.
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%, meaning every parameter already has a description. The tool description itself adds no meaning about parameters beyond stating the generic object focus, so it doesn't improve on the schema. 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?
The description states a clear verb and resource: 'Show detailed information about a database object.' It distinguishes itself from list_objects implicitly by focusing on details of a single object, but it does not explicitly name a sibling or scope, so it lacks the explicit differentiation seen in top-tier definitions.
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 provides no guidance on when to use this tool versus alternatives like list_objects explain_query or analyze_* tools. The only situational note ('call list_databases first...') lives in the schema's database parameter description, not in the tool description, so no direct usage guidance is offered.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_top_queriesARead-only
Reports the slowest or most resource-intensive queries using data from the 'pg_stat_statements' extension.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | Number of queries to return when ranking based on mean_time or total_time | |
| sort_by | No | Ranking criteria: 'total_time' for total execution time or 'mean_time' for mean execution time per call, or 'resources' for resource-intensive queries | resources |
| database | No | Optional. Alias of the configured database to query. Omit it when the server has only one database configured. When several are configured this parameter is required - call list_databases first to learn the valid aliases. |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description uses 'reports', which aligns with the readOnlyHint annotation and confirms the read-only behavior. It adds a useful behavioral detail that data comes from the pg_stat_statements extension, implying a prerequisite. However, it does not describe what happens if the extension is unavailable or any other operational characteristics beyond what annotations already cover.
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?
The description is a single, focused sentence with no redundant or extraneous information. It front-loads the core purpose and names the data source efficiently, achieving high conciseness.
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 has a complete input schema and an output schema, so return values are covered. The description states the purpose and data source but omits prerequisites (e.g., the pg_stat_statements extension being enabled) and does not provide guidance on choosing this over sibling analysis tools. For a simple read-only tool, this is adequate but not rich.
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?
The input schema provides 100% coverage with detailed descriptions for all three parameters (limit, sort_by, database). The tool description adds no parameter-specific meaning beyond what the schema already states, so the baseline of 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?
The description states a specific verb ('reports') and resource ('slowest or most resource-intensive queries'), and names the data source (pg_stat_statements), which clearly conveys the tool's function. It does not explicitly differentiate from sibling tools like explain_query or analyze_workload_indexes, but the purpose is distinct enough to be understood 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?
The description implies the tool is used to retrieve top queries by performance, giving a clear use case. However, it provides no explicit guidance on when to use this tool versus alternatives, no exclusions, and does not reference any sibling tools, leaving routing decisions to the agent's inference.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_databasesARead-only
List the databases this server is configured to query, along with the tables each policy allows and the effective access mode and policy enforcement state for each. Every alias returned here can be passed as the 'database' parameter of the other tools. Reads the server's policy configuration only - it does not connect to any database and never returns credentials.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description adds meaningful behavioral context beyond the readOnlyHint annotation by stating it reads only the server's policy configuration, does not connect to any database, and never returns credentials. This reassures the agent about safety, side effects, and sensitive data exposure.
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?
The description is three sentences, each earning its place: what the tool returns, how the output connects to other tools, and what the tool does not do. It is front-loaded with the core purpose and contains no filler.
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?
Given zero parameters, an output schema, and readOnlyHint annotations, the description is complete enough for an agent to call the tool correctly. It explains the output's role, the access mode/policy state, and the safety profile without needing to duplicate structured schema data.
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?
The tool has zero parameters, so there is no parameter meaning for the description to add. The baseline of 4 is appropriate because no parameter documentation is needed and the description focuses on the valuable output semantics instead.
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 states a specific verb and resource: it lists databases the server is configured to query, along with tables, access mode, and policy enforcement state. It also distinguishes itself from sibling tools by clarifying these are database aliases usable as the 'database' parameter for other tools, not schemas, objects, or query execution.
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 gives clear context for use by noting every returned alias can be passed as the 'database' parameter of other tools. It does not explicitly name sibling alternatives or include when-not-to-use conditions, so it stops short of full routing guidance.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_objectsBRead-only
List objects in a schema
| Name | Required | Description | Default |
|---|---|---|---|
| database | No | Optional. Alias of the configured database to query. Omit it when the server has only one database configured. When several are configured this parameter is required - call list_databases first to learn the valid aliases. | |
| object_type | No | Object type: 'table', 'view', 'sequence', or 'extension' | table |
| schema_name | Yes | Schema name |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations include readOnlyHint=true, so the read-only nature is already disclosed. The description adds no further behavioral context such as pagination, ordering, limits, or performance implications. Since the description carries the burden when annotations are sparse, this one-liner contributes little beyond the annotation itself.
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?
The description is a single sentence that is clear and to the point. It front-loads the purpose and contains no redundant phrases. This is an example of appropriate conciseness, though it may be too terse for behavioral transparency.
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 are covered. The parameter schema documents all fields including optional database and object_type with defaults. The description, while minimal, is sufficient for a straightforward list operation; nothing critical is missing that an agent would need to invoke it correctly.
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%, meaning all parameters (database, object_type, schema_name) have descriptions in the input schema. The tool description does not add any parameter-related meaning beyond what the schema already provides. Baseline 3 applies because the schema fully documents parameters, and the description adds no extra value.
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 clearly states a verb (list) and resource (objects in a schema). It distinguishes itself from siblings like list_databases and list_schemas by targeting objects within a schema, though it doesn't explicitly contrast with get_object_details which focuses on a single object's details. The core purpose is unambiguous.
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 provides no guidance on when to use this tool versus alternatives. It does not mention that get_object_details is for fetching details of a single object, or that list_schemas/list_databases serve different scopes. An agent would have to infer usage from the schema and sibling names.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_schemasARead-only
List all schemas in the database
| Name | Required | Description | Default |
|---|---|---|---|
| database | No | Optional. Alias of the configured database to query. Omit it when the server has only one database configured. When several are configured this parameter is required - call list_databases first to learn the valid aliases. |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true, so the safety profile is covered. The description adds the scoping detail that all schemas are returned, but it does not disclose additional behavioral traits such as failure modes or prerequisites. This is minimally sufficient given the annotation.
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?
The description is a single concise sentence with no filler or redundant information. It is front-loaded with the verb and resource, and every word contributes to understanding.
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?
This is a simple read-only list operation with one optional parameter, an output schema, and a safety annotation. The description plus the structured schema fully cover what an agent needs to invoke the tool correctly.
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 the parameter's description already explains optionality, default behavior, and the prerequisite to call list_databases first. The tool description itself adds no parameter-specific meaning, so 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?
The description uses a specific verb and resource ('List all schemas'), which is unambiguous and clearly distinguishes this tool from siblings like list_databases and list_objects. The word 'all' sets a clear scoping expectation.
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 purpose: use when you need the list of schemas. However, the description does not explicitly name alternatives or exclusions. The parameter schema does instruct calling list_databases first when multiple databases are configured, which provides indirect usage guidance, but the tool description itself lacks this.
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.
10 tool updates
v0.1.0- First observed
analyze_db_health - First observed
analyze_query_indexes - First observed
analyze_workload_indexes - First observed
execute_sql - First observed
explain_query - First observed
get_object_details - First observed
get_top_queries - First observed
list_databases - First observed
list_objects - First observed
list_schemas
TDQS
Scored across 10 tools
Each tool maps to a distinct resource or analysis task: listing hierarchy (databases/schemas/objects), object details, query planning, index analysis, health checks, top queries, and arbitrary SQL execution. The two index-analysis tools are differentiated by workload-derived vs caller-supplied queries, and the descriptions make that clear.
All tool names follow a consistent lowercase snake_case verb_noun pattern (list_*, get_*, analyze_*, explain_query, execute_sql). This makes the tool surface predictable and easy to navigate.
Ten tools cover a Postgres administration/analysis server without redundancy. Each tool earns its place, and the count is squarely in the well-scoped range.
The surface covers inspection of the database hierarchy, object details, query plans, index recommendations, health checks, top queries, and arbitrary SQL execution, so agents can complete common diagnostic and maintenance tasks without dead ends. The health-check tool bundles multiple sub-checks, which helps keep the surface compact while remaining comprehensive.
Maintenance
Related MCP Connectors
Deterministic safety, correctness & cost gate that vets Postgres SQL before your AI agent runs it.
Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
Analytical memory for AI agents: a real Postgres queried in plain English over MCP. One command.
- XataOAuthio.github.xataio
Xata MCP server lets AI agents interact with your Xata projects, and Postgres database branches.
Related MCP Servers
- AlicenseBqualityDmaintenanceAn open source Model Context Protocol server for PostgreSQL that provides database health analysis, index tuning, query plan exploration, and safe SQL execution for AI agents throughout the development process.9MIT
- AlicenseNot gradedqualityNot gradedmaintenanceAn open-source MCP server that provides AI agents with advanced PostgreSQL capabilities including index tuning, query plan optimization, and comprehensive database health analysis. It supports safe SQL execution through configurable access modes and offers both stdio and SSE transport options for various development environments.MIT
- FlicenseNot gradedqualityDmaintenanceAn MCP server that gives an AI agent scoped, safe access to your Postgres databases with per-connection access control, row caps, timeouts, and defense-in-depth read-only enforcement.-
- AlicenseAqualityDmaintenanceA production-grade MCP server that gives AI agents safe, authenticated access to a PostgreSQL database.3MIT