Skip to main content
Glama
gokiwitech

pgwarden-mcp

by gokiwitech

pgwarden-mcp

License: MIT

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 customers is 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 events on 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_activity and 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 --help

Or 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.yaml

or 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 → MCP tab.

  • Windsurf: Command Palette → Open Windsurf Settings Page.

  • Goose: goose configure, then Add Extension.

  • Qodo Gen: Chat panel → Connect more tools → + Add new MCP.

4. What the agent can and cannot do now

Query

Result

SELECT id, email FROM customers WHERE tenant_id = 42

Runs. LIMIT 500 is injected; ajay.ranwa@gokiwi.in comes back as aj********@gokiwi.in.

SELECT id FROM customers

Rejected: tenant_id is in require_predicate_on. (WHERE tenant_id = 99 would run - the rule checks the shape of the predicate, never the value. See it is a guardrail, not tenant isolation.)

SELECT * FROM customers WHERE tenant_id = 42

Rejected: the table restricts its columns, so a wildcard is refused.

SELECT password_hash FROM customers WHERE tenant_id = 42

Rejected: the column is excluded.

SELECT id FROM customers WHERE tenant_id = 42 AND email = 'ceo@acme.com'

Rejected: email is masked, and a predicate over a masked column recovers the value the mask hides.

SELECT count(*) FILTER (WHERE email = 'ceo@acme.com') FROM customers WHERE tenant_id = 42

Rejected: a predicate inside an aggregate's FILTER is still a predicate over a masked column, and the count answers it exactly.

SELECT * FROM audit_log

Rejected: the table is not in the allowlist.

SELECT * FROM pg_stat_activity

Rejected: denied system catalog.

SELECT most_common_vals FROM pg_stats

Rejected: the planner statistics views hold sampled values out of the tables they describe.

DECLARE c CURSOR FOR SELECT id FROM orders

Rejected while enforce_row_limits is on: the rows arrive through a later FETCH that no LIMIT can bound.

SELECT id FROM orders WHERE tenant_id = 1; SELECT email FROM customers

Rejected: agent SQL must be exactly one statement. Postgres runs all of them and returns only the last one's rows.

SELECT id FROM "CUSTOMERS"

Rejected: a quoted mixed-case name is a different relation from customers, and the parse tree does not say which was written.

UPDATE orders SET status = 'x'

Rejected twice over: a policy file forces the parsing, read-only driver even under --access-mode=unrestricted, and the policy engine refuses any statement kind it does not model.

SELECT id FROM orders

Runs, rewritten to SELECT id FROM orders LIMIT 500.

Environment variables

Variable

Default

Meaning

PGWARDEN_CONFIG

unset

Path to the policy file. Equivalent to --config, which takes precedence. Falls back to the deprecated POSTGRES_MCP_CONFIG, with a warning.

PGWARDEN_DATABASE_URI

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 DATABASE_URI, with a warning. The positional CLI argument is used only if neither variable is set.

PGWARDEN_MASK_SALT

unset

Salt for the hash masking strategy, used when a rule and its database policy do not name one. Falls back to the deprecated POSTGRES_MCP_MASK_SALT, with a warning.

PGWARDEN_INCLUDE_LANGFUSE_TRACE

true

Set to false to drop the _langfuse_trace key from index-tuning tool output. Falls back to the deprecated POSTGRES_MCP_INCLUDE_LANGFUSE_TRACE, with a warning.

OPENAI_API_KEY

unset

Required only by the experimental method="llm" index tuning.

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 / ANALYZE and 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 / exclude rules,

  • 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 / ROLLUP key, an aggregate's FILTER (WHERE ...) condition, a JOIN ... USING (col) column, a NATURAL JOIN over 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_stats and the pg_stats_ext pair.

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 ... CURSOR that 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 ...) or count(nullif(...)), which are predicates dressed as values), a whole-row reference, a renamed wildcard, a SELECT * 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

connection_url

string

required

libpq connection URI. Must not be empty. Use ${VAR} for the password. Held as a secret internally and never returned by any tool, logged, or echoed into a validation error.

access_mode

unrestricted | restricted

inherits --access-mode

Per-database override of the server's access mode. See Per-database access mode - in short, unrestricted means this database gets no policy engine at all, so it may not also set any other field in this table.

default_row_limit

integer > 0

500

LIMIT injected when an agent's query has none (or has LIMIT ALL).

max_row_limit

integer > 0

5000

Ceiling. A larger literal LIMIT is clamped down to it. Must be >= default_row_limit.

enforce_row_limits

boolean

true

Turns row limit injection and clamping off entirely - and, with it, the blanket refusal of cursors.

allowed_schemas

list of strings

["public"]

Referencing a table in any other schema is rejected. "*" allows every schema. Unqualified table names are treated as public (bare pg_* names as pg_catalog). An empty list is rejected.

tables

map of name to table policy

{}

The table allowlist. An empty map allows nothing. Keys may be bare (orders) or qualified (reporting.revenue); a qualified key's schema must be in allowed_schemas. "*" as a key allows every table in the allowed schemas.

system_catalog_access

deny | allow

deny

allow opens all of pg_catalog and information_schema.

allowed_system_catalogs

list of strings

built-in list

Which catalogs survive deny. Setting this replaces the built-in list rather than extending it. Entries are bare catalog names, schema.name, or schema.*.

mask_salt

string or null

null

Salt for the hash masking strategy. At least 8 characters; 16 or more recommended (shorter logs a warning). Falls back to the PGWARDEN_MASK_SALT environment variable. Held as a secret, like connection_url.

Table options

Field

Type

Default

Meaning

columns.mode

all | include | exclude

all

include exposes only the listed columns; exclude exposes everything but them. all restricts nothing.

columns.list

list of strings

[]

Must be non-empty when the mode is include or exclude, and must be empty when the mode is all. Matched case-insensitively.

require_predicate_on

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.

mask

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 NULL and col IS NULL - on a NOT NULL column the first is every row of the table, so it bounds nothing;

  • col IN (SELECT ...) and col = ANY (SELECT ...) - the agent writes that subquery too, so IN (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 OR or NOT, one in an outer join's ON clause, 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

require_predicate_on on a column the same table's columns rule hides

The predicate is mandatory and referencing the column is forbidden, so every query on the table would be rejected. Add the column to an include list, remove it from an exclude list, or drop it from require_predicate_on.

require_predicate_on on a column the same table masks

A masked column may not be used in WHERE / ORDER BY / GROUP BY (see below), so the required predicate can never be written. Keep the require_predicate_on entry or the mask, not both.

Per-table rules under the "*" table key ("*": {columns: ...})

"*" is a blanket grant, and per-table rules are matched by exact name, so the restriction would silently cover nothing. Move the rules to each table's own key, or write "*": {}.

An unquoted strategy: null

In YAML that is the absent value, not the null masking strategy. Quote it - strategy: "null".

columns.mode: all with a non-empty columns.list

mode: all makes every column readable and ignores the list, so the named columns stay readable while the config reads as though they were restricted. Use include or exclude, or delete the list.

allowed_schemas: []

A relation is matched against allowed_schemas before any per-table rule, so an empty list rejects every table reference - including the tables listed under tables. List the schemas to read, or ['*'] for all.

access_mode: unrestricted alongside tables, allowed_schemas, mask_salt or any other policy-only field on the same database

unrestricted means this database gets no policy engine at all - the engine only understands SELECT, so a rule written here would silently never apply to the INSERT / UPDATE / DELETE the database actually allows. Remove the policy fields, or set access_mode: restricted to keep them. See Per-database access mode.

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_catalogs list written next to system_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 exclude list 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

redact

placeholder (***REDACTED***)

4111 1111 1111 1111

***REDACTED***

null

none

anything

NULL

hash

salt (required), length (12, range 8-64 hex chars), algorithm (sha256), prefix ("")

user-1042

4e3d5ba31228

partial

show_first (0), show_last (0), mask_char (*), min_hidden (1), mask_length (null), placeholder (***REDACTED***)

4111111111111111 with show_last: 4

************1111

email

show_first (2), mask_char (*), mask_length (null), placeholder (***REDACTED***)

ajay.ranwa@gokiwi.in

aj********@gokiwi.in

phone

show_last (4), mask_char (*), placeholder (***REDACTED***)

+1 (555) 123-4567

*******4567

regex

pattern (required), replacement (***), flags ("", from aimsux), count (0 = all), max_input_length (4096), placeholder (***REDACTED***)

ACC-4815162342 with pattern: "\d", replacement: "#"

ACC-##########

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

partial show_first: 2, show_last: 2

abcdefgh

ab****gh

The middle is hidden one character per character.

partial show_first: 2, show_last: 2

abc

***

Fewer than min_hidden characters would be hidden, so nothing is shown. Short values never leak.

partial show_last: 4, mask_length: 3

4111111111111111

***1111

mask_length hides the value's length as well.

partial show_last: 2

123456 (integer)

****56

Non-text scalars are stringified and masked as text, so a masked integer comes back as a string.

partial show_last: 4

{"a": 1} (JSONB)

***REDACTED***

Structured and binary values are always fully redacted; partially masking a JSON dump would leak keys and can produce invalid JSON.

email show_first: 2

aj@gokiwi.in

**@gokiwi.in

The local part is never shown in full.

email

not-an-address

***REDACTED***

Anything that is not a well-formed address - no @, an empty local part or domain, embedded whitespace - falls back to full redaction rather than partial masking, which could echo the raw value straight back out.

phone

unknown

***REDACTED***

A value with no ASCII digits is not a phone number, so it is not passed through. Separators and punctuation are dropped, not preserved.

hash length: 16, prefix: "u_"

user-1042

u_4e3d5ba312281547

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: GROUP BY and JOIN ... ON over a masked column are rejected.

any

NULL

NULL

null is the only strategy that turns a value into NULL.

any except null / hash

''

''

The empty string carries no PII and stays distinguishable from NULL. hash stays total so that grouping buckets remain consistent.

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

SELECT * FROM customers WHERE tenant_id = 42

'*' is not allowed on table 'public.customers' ... the table restricts which columns are readable (columns.mode='exclude') ... Every column of 'public.customers' is readable except: internal_notes, password_hash.

Name the columns: SELECT id, email FROM customers WHERE tenant_id = 42. Same for c.* and for the whole-row form SELECT c FROM customers c.

SELECT password_hash FROM customers c JOIN orders o ON o.id = c.id WHERE c.tenant_id = 1

Unqualified column 'password_hash' cannot be attributed to a table: this query level has more than one FROM item ...

Qualify it: SELECT c.password_hash ... - which then correctly reports that the column is hidden. Any unqualified name that could be a hidden column is refused when more than one relation is in scope.

SELECT email FROM contacts JOIN leads ON leads.id = contacts.id (both tables mask email)

Unqualified column 'email' could come from more than one masked source (public.contacts.email, public.leads.email) ...

Qualify it: SELECT contacts.email ....

SELECT id FROM orders LIMIT $1

The LIMIT of this query is not a plain integer literal, so the policy ... cannot verify it stays within the maximum of 5000 rows.

Write a literal: SELECT id FROM orders LIMIT 500. Parameters, scalar subqueries and computed expressions cannot be bounded without running the query. LIMIT 10.0 is refused for the same reason.

SELECT id FROM orders ORDER BY id FETCH FIRST 10 ROWS WITH TIES

'WITH TIES' is not allowed ... it can return unboundedly many rows regardless of the row limit.

FETCH FIRST 10 ROWS ONLY, or a plain LIMIT 10. WITH TIES over a constant sort key returns the whole table.

DECLARE c CURSOR FOR SELECT id FROM orders

Declaring a cursor is not allowed by the policy ... while row limits are enforced (default 500 rows, maximum 5000). A cursor's rows are returned by later FETCH statements, which the policy cannot bound ...

Run the SELECT directly, adding LIMIT n (n <= max_row_limit) if the default is not enough. Every cursor is refused while enforce_row_limits is on, over any table. With row limits switched off a cursor is allowed again - unless it reads a masked column, which is refused separately: This query declares a cursor over a column that the policy ... masks ... the rows of a cursor are returned by a later FETCH that the policy cannot attribute to a table.

SELECT id FROM customers WHERE tenant_id = 1 AND email = 'ceo@acme.com'

Column 'email' of table 'public.customers' is masked by the policy ..., so it may not be filtered on, joined on, grouped by, partitioned by or ordered by ... a predicate over it still reveals the real value.

Select the column and compare on an unmasked one: SELECT id, email FROM customers WHERE tenant_id = 1. The same rejection covers ORDER BY email, GROUP BY email, HAVING max(email) > 'm', JOIN ... ON u.email = ..., DISTINCT ON (email), OVER (PARTITION BY email) and a masked column inside a WHERE ... IN (SELECT ...) sublink.

SELECT email AS contact FROM customers WHERE tenant_id = 1 ORDER BY contact

'ORDER BY contact' names an output column that the policy ... masks. ORDER BY is evaluated on the real value, before the mask is applied to the returned rows ...

Drop the clause, or order by an unmasked column. Positional keys are caught the same way: ORDER BY 1 and GROUP BY 1 over a masked target-list entry are refused.

SELECT id, email FROM customers UNION ALL SELECT id, email FROM leads ORDER BY 2

'ORDER BY position 2' names an output column that the policy ... masks. ORDER BY is evaluated on the real value, before the mask is applied to the returned rows, so it would order or group by data the mask is meant to hide.

Order by an unmasked position - ORDER BY 1 on this query still runs. A set operation has no target list of its own, so the position is resolved against the merged shape of its arms: names come from the leftmost arm (so ORDER BY <alias> is refused too) and a position is masked if either arm masks it. Nesting and all of UNION / UNION ALL / INTERSECT / EXCEPT are covered.

SELECT * FROM customers ORDER BY 2

'ORDER BY 2' points at a column of a '*' that expands over a table the policy ... masks ... it cannot tell whether position 2 is the masked column or a harmless one. It is refused rather than guessed.

Name the output columns - SELECT id, email FROM customers ORDER BY 1 runs. Only the catalog knows the width and column order a * expands to, so no position behind one can be resolved; ORDER BY 1 is refused for the same reason, as is the wildcard reaching the position through a subquery, a column alias list, a CTE or TABLE customers UNION ALL TABLE leads. A wildcard over a table with no mask rules is unaffected.

WITH c AS (SELECT email FROM customers) SELECT * FROM c WHERE c.email = 'x'

Column 'c.email' cannot be used as a filter, join key, grouping or ordering key: this statement reads a table whose column 'email' is masked ..., and the reference comes out of a CTE or a subquery ...

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: WITH c AS (SELECT email AS e FROM customers) SELECT * FROM c WHERE c.e = 'x' is refused too, because the check follows the same provenance the mask plan does.

SELECT count(*) FILTER (WHERE email = 'ceo@acme.com') FROM customers WHERE tenant_id = 1

Column 'email' ... is masked by the policy ..., so it may not be filtered on, joined on, grouped by, partitioned by or ordered by ...

An aggregate's FILTER condition decides which rows are counted, so the count answers the comparison exactly - an equality test with =, a binary search with <. Drop the FILTER, or filter on an unmasked column.

SELECT count(CASE WHEN email LIKE 'a%' THEN 1 END) FROM customers WHERE tenant_id = 1

This query computes an expression over column(s) the policy ... masks ... count() stays allowed only while nothing inside it filters on the masked value: 'count(*) FILTER (WHERE email = ...)' and 'count(CASE WHEN email ... THEN 1 END)' answer the comparison through the count itself ...

Same shape, written as a value. count(nullif(email, 'ceo@acme.com')) is refused for the same reason - it is one less than count(email) exactly when the guess is right. Plain count(email) is still allowed.

SELECT l.id FROM leads l JOIN contacts c USING (email)

Column 'email' ... is masked by the policy ..., so it may not be ... joined on ...

USING (col) is ON a.col = b.col, so it is a predicate over the column even though no ColumnRef is written. Name an unmasked join key, or write the ON clause out. A USING column that a table hides is refused too, as an unqualified reference that cannot be attributed.

SELECT l.id FROM leads l NATURAL JOIN contacts c

'NATURAL JOIN' is not allowed on table 'public.leads' ... a natural join is implicitly joined on every column name the two relations share. The policy layer has no catalog, so it cannot enumerate those names ...

Write the join condition out with ON, naming the columns you mean. NATURAL JOIN is refused whenever either side carries columns or mask rules at all - not only when a restricted column is actually shared, because that cannot be known without a catalog.

SELECT email, count(*) FROM customers WHERE tenant_id = 1 GROUP BY ROLLUP (email)

'GROUP BY email' names an output column that the policy ... masks. GROUP BY is evaluated on the real value, before the mask is applied ...

Group by an unmasked column. GROUPING SETS, CUBE and ROLLUP are unwrapped and checked like a plain GROUP BY, including the positional form GROUP BY GROUPING SETS ((1)).

SELECT 1 AS x, 'a' AS y UNION ALL SELECT * FROM contacts

One arm of this UNION / INTERSECT / EXCEPT selects '*' over a table the policy ... masks (email), and another arm names its output columns. Postgres names the result after the leftmost arm ... it cannot tell which output column the masked value lands in.

Name the columns explicitly in every arm. Position, not name, is what lines set-operation arms up, and a * arm has no positions this layer can resolve - so the mask would be keyed by a column name that never appears in the result.

SELECT email FROM contacts WHERE false UNION ALL SELECT 'ceo@acme.com'

Output column 'email' (position 1) ... combines a column the policy ... masks with a constant written into the query ... which turns the query into a chosen-plaintext oracle: the caller learns exactly what the mask (and, for a hashing strategy, the salt) does to any value it picks ...

Select the masked column on its own. Any constant arm counts - a literal, a VALUES row, a cast or a computed constant - in any of UNION, INTERSECT and EXCEPT. See hash is a pseudonym, not a secret for why this one matters most.

SELECT id FROM "CUSTOMERS"

Relation name 'CUSTOMERS' contains upper-case characters, which the policy ... cannot resolve safely. Postgres treats an unquoted 'USERS' and a quoted '"USERS"' as two different relations, and the parse tree does not record which one was written ...

Write the relation in lower case. The allowlist folds case but Postgres does not, so "CUSTOMERS" would borrow the rules written for customers while reading a different table. An unquoted SELECT ... FROM CUSTOMERS is fine - Postgres folds it, and so does the parser. A mixed-case schema name is refused the same way; a mixed-case relation cannot be allowlisted at all.

UPDATE orders SET status = 'x', COPY orders TO STDOUT, PREPARE p AS SELECT ..., CREATE TABLE ...

Statements of kind 'UpdateStmt' are not allowed by the policy ...: the policy engine only models read-only queries (SELECT, EXPLAIN, SHOW, VACUUM/ANALYZE and the cursor statements) ... Data-modifying statements, COPY and PREPARE are refused rather than passed through unchecked.

Rewrite as a SELECT. The read-only driver already refuses these, but the policy engine does not depend on that: a statement shape none of the checks model is refused by the engine itself, so a policy engine driven from anywhere else still fails closed.

SELECT id FROM orders WHERE tenant_id = 1; SELECT email FROM customers

Agent-supplied SQL must be exactly one statement, but 2 were found. Running several statements in one request executes all of them and returns only the last one's rows, which is not something the policy can present honestly.

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 ; bought a second query that no row limit bounded and no mask plan covered. Agent text is now parsed on its own and re-attached as an AST node instead of being concatenated into a larger string.

SELECT most_common_vals FROM pg_stats WHERE tablename = 'orders'

Column 'most_common_vals' of the planner statistics view 'pg_stats' is not readable ... The 'tablename' predicate cannot be trusted to keep them out, so the columns are refused outright.

Name the summary columns instead: SELECT schemaname, tablename, attname, null_frac, avg_width, n_distinct, correlation FROM pg_stats. SELECT * over pg_stats is refused for the same reason.

SELECT upper(c.email) FROM customers c WHERE c.tenant_id = 1

This query computes an expression over column(s) the policy ... masks: public.customers.email.

SELECT c.email and let the client uppercase it. The same applies to email || '...', substring(ssn, 1, 3), max(email), min, array_agg, string_agg, json_build_object, row(email), casts, and a masked column inside a scalar subquery or a VALUES list. Bare count(email) is allowed in the target list: it returns a row count, not a value. (In a HAVING or WHERE clause it is refused like any other predicate over the column - and see the FILTER and CASE rows above for the forms of count() that carry a predicate inside them.)

SELECT id FROM customers (with require_predicate_on: [tenant_id])

Query on table 'public.customers' must constrain column 'tenant_id' ... Add a predicate that pins it to values you name, such as 'customers.tenant_id = <value>' ... It must be reachable through AND only ... A predicate that only tests for presence does not count either: 'customers.tenant_id IS NOT NULL' and 'customers.tenant_id IS NULL' both leave the rows unbounded, and so does comparing the column against a subquery instead of against literal values.

SELECT id FROM customers WHERE tenant_id = 42. WHERE tenant_id IS NOT NULL, WHERE tenant_id IN (SELECT id FROM tenants) and WHERE tenant_id > 5 are all refused: none of them bounds the rows to values the query named.

WITH m(x) AS (SELECT * FROM contacts) SELECT x FROM m (where contacts masks email)

CTE 'm' renames its output columns but selects '*' over a table the policy ... masks (email) ... it cannot tell which renamed column carries the masked value.

Name the columns in the inner SELECT. A column alias list cannot be lined up against a * without a catalog. The same applies to (SELECT * FROM contacts) s(x).

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_databases returns each alias with its effective access_mode, a policy_enforced boolean, allowed_schemas, allowed tables, system_catalog_access, default_row_limit and max_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 database parameter. 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 --access-mode

database access_mode

result

restricted

unrestricted

still read-only and policy-checked - the flag wins

unrestricted

restricted

policy-enforced, read-only

unrestricted

unrestricted

unpoliced, read/write

unrestricted

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, a pg_stats value column, a statement kind it does not model, a mixed-case relation name - is rejected rather than executed.

  • A misconfiguration fails open. columns.mode: all on a PII table with no mask rules is a valid, silent, wide-open policy. So is tables: {"*": {}}, and so is system_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 review GRANT statements.

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 reading current_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 users is 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 text explain_query embeds). 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 users runs. DISTINCT over 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 a hash mask 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, in UNION, INTERSECT and EXCEPT alike. 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 length for large domains. The maximum is 64 hex characters, the minimum 8. length: 32 costs 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:

  • OFFSET paging 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 ... CURSOR used to walk past the limit entirely, because the injected LIMIT only ever lands on a SELECT and the rows arrive through a later FETCH ALL that this layer never sees. Declaring a cursor is now refused outright while enforce_row_limits is on, over any table. Set enforce_row_limits: false if 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 later FETCH.

  • A second statement after a ;. psycopg executes every statement in the string it is given and returns only the last one's rows, so SELECT 1; SELECT email FROM customers used to run a query that neither the injected LIMIT nor 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 that explain_query needs, 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_stats is allowed by default, but its sample values are not. The view's most_common_vals, most_common_elems, histogram_bounds and elem_count_histogram columns 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 to customers, not to a catalog view about customers. Those four columns are therefore refused on pg_stats, pg_stats_ext and pg_stats_ext_exprs whenever any table in the policy configures columns or mask, and so is a SELECT * that would expand over them. The WHERE tablename = '...' predicate is deliberately not consulted: it is agent-controlled and may be absent, negated or OR-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 from pg_stat_statements still has $1 placeholders, the parameter substitution used by explain_query and the index advisor tries to pick a realistic value out of most_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_schema is fully readable by default (information_schema.*), so the existence and shape of tables outside the allowlist is discoverable even though their contents are not.

  • EXPLAIN output is not masked or limited, as described in What is enforced, and where.

  • Postgres still filters pg_stats by 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:

  • connection reads pg_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_activity

    Or scope the call and skip it: analyze_db_health(health_type="index,vacuum,buffer,constraint,replication").

  • sequence reads each sequence relation directly, and a sequence is never written under tables:. The policy engine therefore permits a sequence named by Postgres's serial / identity convention - <allowlisted_table>_<column>_seq, in a schema the policy allows - without it being listed: users_id_seq and user_accounts_external_id_seq resolve as long as users and user_accounts are allowlisted. Anything outside that convention (a hand-created CREATE SEQUENCE order_numbers, a renamed sequence, one owned by a table that is not allowlisted) is still refused - as is a sequence whose SELECT grant 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 under tables: if the check needs to cover them. The exemption is name-based, not catalog-based: an ordinary table called <allowlisted-table>_<something>_seq becomes 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: {}} with allowed_schemas: [public, reporting] allows both public.orders and reporting.orders. Write the key qualified when you mean one of them.

  • allowed_system_catalogs replaces 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_on column must be both readable and unmasked. Hiding it with columns, 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_on is not tenant isolation, whatever the column is called. It checks that a predicate exists, not what it says, so WHERE 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_filters is not a valid key. It was the pre-release name of require_predicate_on and 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.

  • --config implies 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_sql will then reject DDL and DML - and the policy engine independently refuses any statement kind it does not model, so DML, COPY, PREPARE and DDL are rejected twice over. This is deliberate; it is also a surprise if you expected unrestricted to still write.

  • Every renamed environment variable keeps its old name working, including POSTGRES_MCP_MASK_SALT. PGWARDEN_MASK_SALT, PGWARDEN_CONFIG, PGWARDEN_DATABASE_URI and PGWARDEN_INCLUDE_LANGFUSE_TRACE all 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

list_databases

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.

list_schemas

Lists all schemas in the instance.

list_objects

Lists database objects (tables, views, sequences, extensions) within a schema.

get_object_details

Describes one object - a table's columns, constraints and indexes, for example.

execute_sql

Runs a SQL statement. The only tool through which agent SQL reaches the database as rows, so row limits and masking apply here.

explain_query

Returns the execution plan for a query, optionally with analyze=True (real execution statistics) or a list of hypothetical_indexes to simulate. Carries agent SQL, but returns a plan rather than rows, so it is checked and not masked.

get_top_queries

Reports the slowest or most resource-intensive queries from pg_stat_statements. sort_by is one of resources, mean_time, total_time.

analyze_workload_indexes

Analyzes the workload to find resource-intensive queries, then recommends indexes. method is dta (default) or the experimental llm.

analyze_query_indexes

Recommends indexes for a list of up to 10 supplied queries.

analyze_db_health

Runs health checks. health_type is all (default) or a comma-separated subset of index, connection, vacuum, sequence, replication, buffer, constraint.

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.

  • restricted limits 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_statements records the runtime and resource consumption of each query, which is how the server finds tuning targets.

  • hypopg simulates 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:

  1. Identify queries worth tuning. Either you supply them, or the workload is analyzed through pg_stat_statements using 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.

  2. 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.

  3. Search for the best configuration. hypopg provides "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 around LIKE).

  4. 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.0

Client 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 sync

Run 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=INFO

The 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

src/pgwarden_mcp/policy/models.py

The config schema. Every YAML field and every load-time validation.

src/pgwarden_mcp/policy/loader.py

File reading, post-parse ${VAR} interpolation, mask-rule validation, warnings.

src/pgwarden_mcp/policy/engine.py

The parse-tree checks, the row-limit rewrite and the mask planner.

src/pgwarden_mcp/policy/masking.py

The masking strategies and their parameter validation.

src/pgwarden_mcp/policy/catalogs.py

System catalog allowlist, denylist and the statistics-view column rules.

src/pgwarden_mcp/policy/registry.py

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 tools
analyze_db_healthA
Read-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.

ParametersJSON Schema
NameRequiredDescriptionDefault
databaseNoOptional. 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_typeNoOptional. Valid values are: all, buffer, connection, constraint, index, replication, sequence, vacuum.all

Output Schema

ParametersJSON Schema
NameRequiredDescription
resultYes

TDQS

A4.2/5.0
Behavior4/5

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.

Conciseness4/5

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.

Completeness5/5

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.

Parameters4/5

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.

Purpose5/5

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.

Usage Guidelines3/5

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_indexesA
Read-only

Analyze a list of (up to 10) SQL queries and recommend optimal indexes

ParametersJSON Schema
NameRequiredDescriptionDefault
methodNoMethod to use for analysisdta
queriesYesList of Query strings to analyze
databaseNoOptional. 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_mbNoMax index size in MB

Output Schema

ParametersJSON Schema
NameRequiredDescription
resultYes

TDQS

A3.5/5.0
Behavior3/5

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.

Conciseness5/5

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.

Completeness4/5

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.

Parameters4/5

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.

Purpose4/5

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.

Usage Guidelines2/5

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_indexesB
Read-only

Analyze frequently executed queries in the database and recommend optimal indexes

ParametersJSON Schema
NameRequiredDescriptionDefault
methodNoMethod to use for analysisdta
databaseNoOptional. 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_mbNoMax index size in MB

Output Schema

ParametersJSON Schema
NameRequiredDescription
resultYes

TDQS

B3.3/5.0
Behavior3/5

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.

Conciseness5/5

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.

Completeness3/5

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.

Parameters3/5

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.

Purpose4/5

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.

Usage Guidelines2/5

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_sqlC
Destructive

Execute any SQL query

ParametersJSON Schema
NameRequiredDescriptionDefault
sqlNoSQL to runall
databaseNoOptional. 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

ParametersJSON Schema
NameRequiredDescription
resultYes

TDQS

C2.4/5.0
Behavior2/5

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.

Conciseness3/5

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.

Completeness3/5

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.

Parameters3/5

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.

Purpose3/5

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.

Usage Guidelines1/5

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_queryB
Read-only

Explains the execution plan for a SQL query, showing how the database will execute it and provides detailed cost estimates.

ParametersJSON Schema
NameRequiredDescriptionDefault
sqlYesSQL query to explain
analyzeNoWhen True, actually runs the query to show real execution statistics instead of estimates. Takes longer but provides more accurate information.
databaseNoOptional. 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_indexesNoA 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

ParametersJSON Schema
NameRequiredDescription
resultYes

TDQS

B3.1/5.0
Behavior2/5

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.

Conciseness5/5

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.

Completeness3/5

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.

Parameters3/5

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.

Purpose4/5

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.

Usage Guidelines2/5

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_detailsB
Read-only

Show detailed information about a database object

ParametersJSON Schema
NameRequiredDescriptionDefault
databaseNoOptional. 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_nameYesObject name
object_typeNoObject type: 'table', 'view', 'sequence', or 'extension'table
schema_nameYesSchema name

Output Schema

ParametersJSON Schema
NameRequiredDescription
resultYes

TDQS

B3.2/5.0
Behavior3/5

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.

Conciseness4/5

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.

Completeness3/5

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.

Parameters3/5

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.

Purpose4/5

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.

Usage Guidelines2/5

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_queriesA
Read-only

Reports the slowest or most resource-intensive queries using data from the 'pg_stat_statements' extension.

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNoNumber of queries to return when ranking based on mean_time or total_time
sort_byNoRanking criteria: 'total_time' for total execution time or 'mean_time' for mean execution time per call, or 'resources' for resource-intensive queriesresources
databaseNoOptional. 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

ParametersJSON Schema
NameRequiredDescription
resultYes

TDQS

A3.5/5.0
Behavior3/5

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.

Conciseness5/5

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.

Completeness3/5

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.

Parameters3/5

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.

Purpose4/5

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.

Usage Guidelines3/5

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_databasesA
Read-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.

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

Output Schema

ParametersJSON Schema
NameRequiredDescription
resultYes

TDQS

A4.7/5.0
Behavior5/5

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.

Conciseness5/5

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.

Completeness5/5

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.

Parameters4/5

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.

Purpose5/5

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.

Usage Guidelines4/5

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_objectsB
Read-only

List objects in a schema

ParametersJSON Schema
NameRequiredDescriptionDefault
databaseNoOptional. 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_typeNoObject type: 'table', 'view', 'sequence', or 'extension'table
schema_nameYesSchema name

Output Schema

ParametersJSON Schema
NameRequiredDescription
resultYes

TDQS

B3.2/5.0
Behavior2/5

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.

Conciseness5/5

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.

Completeness4/5

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.

Parameters3/5

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.

Purpose4/5

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.

Usage Guidelines2/5

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_schemasA
Read-only

List all schemas in the database

ParametersJSON Schema
NameRequiredDescriptionDefault
databaseNoOptional. 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

ParametersJSON Schema
NameRequiredDescription
resultYes

TDQS

A3.9/5.0
Behavior3/5

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.

Conciseness5/5

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.

Completeness5/5

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.

Parameters3/5

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.

Purpose5/5

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.

Usage Guidelines3/5

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.

  1. 10 tool updatesv0.1.0
    • First observedanalyze_db_health
    • First observedanalyze_query_indexes
    • First observedanalyze_workload_indexes
    • First observedexecute_sql
    • First observedexplain_query
    • First observedget_object_details
    • First observedget_top_queries
    • First observedlist_databases
    • First observedlist_objects
    • First observedlist_schemas

TDQS

A3.7/5.0

Scored across 10 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness5/5

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

ActivitySlowing
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    D
    maintenance
    An 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.
    9
    MIT
  • A
    license
    Not graded
    quality
    Not graded
    maintenance
    An 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
  • F
    license
    Not graded
    quality
    D
    maintenance
    An 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.
    -