Astraeus Governed Postgres MCP
Provides policy-governed, read-only query access to a PostgreSQL database, exposing tools to list and describe allowlisted tables and to run a single validated single-table SELECT, with table/column allowlists, row limits, per-table predicates, PII masking, READ ONLY transactions with timeouts, and an audit log of every allow and deny.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Astraeus Governed Postgres MCPlist the tables I'm allowed to query"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Astraeus Governed Postgres MCP
A local-first, read-only Model Context Protocol server that lets an AI agent query a Postgres database through a policy layer instead of a raw connection string.
Today an agent that needs business data either gets a full database password (too open) or nothing (too locked). This server is the locked door with a keyed hatch: scoped tools, table and column allowlists, row limits, PII masking and an audit log.
Built by Astraeus Business Solutions for Irish and UK SME installs. The product design lives in the Astraeus Agentic Satellite Vault at Workspace/Grok/Astraeus/Astraeus Governed Postgres MCP Design.md (private vault; the acceptance checklist it defines is what v0 implements).
Status: v0. Not yet published to npm; install from source.
What v0 does
Runs over stdio for Claude Code, Cursor, Codex and any other MCP host.
Refuses to start without both a policy file and a database connection string (fail closed).
Exposes five tools:
list_tables,describe_table,query,policy_info,ping.queryaccepts one read-only, single-tableSELECT. It is parsed with a real PostgreSQL parser (pgsql-ast-parser), validated against the policy, and then regenerated from the validated syntax tree. The text you send is never executed; the rewritten statement is.Enforces a table and column allowlist, a row cap, an optional per-table predicate, and PII masking.
Runs every query inside a
READ ONLYtransaction with a statement timeout.Writes an append-only JSONL audit log for every allow and deny.
What v0 does not do: writes of any kind, joins, subqueries, CTEs, UNION, a semantic layer of named views, or hosted HTTP transport. Joins are planned after v0 (see Roadmap).
Related MCP server: Midplane
Quick start
Requires Node 20 or later.
git clone https://github.com/SarutobiSasuke8/astraeus-postgres-mcp.git
cd astraeus-postgres-mcp
npm ci
npm run buildCopy examples/policy.example.yaml, edit it for your schema, and set the connection string in the environment:
export DATABASE_URL='postgresql://astraeus_agent:<password>@db-host:5432/appdb'
node dist/index.js --policy ./my-policy.yamlRegister it with Claude Code:
claude mcp add astraeus-postgres -e DATABASE_URL="$DATABASE_URL" -- node /absolute/path/to/dist/index.js --policy /absolute/path/to/my-policy.yamlOr in any MCP host's JSON config:
{
"mcpServers": {
"astraeus-postgres": {
"command": "node",
"args": ["/absolute/path/to/dist/index.js", "--policy", "/absolute/path/to/my-policy.yaml"],
"env": { "DATABASE_URL": "set this from your secret store" }
}
}
}Configuration:
Setting | Where | Notes |
Policy file |
| Required. YAML or JSON. |
Connection string | Environment variable named by | Required. Never put it in the policy file. |
Audit log path |
| Defaults to |
Credentials are only ever read from the environment. .env.example lists the variable names with placeholder values.
Tools
Tool | What it does |
| Lists the tables and columns the policy allows. Does not touch the database. |
| Column types for an allowlisted table. Only policy columns are shown, and masked columns are flagged. |
| Runs one read-only single-table |
| Returns a summary of the active policy: tables, columns, limits, mask rules. Contains no secrets. |
| Checks connectivity without reading data. |
Denied calls return isError: true with a stable code, for example column_not_allowed, table_not_allowed, multi_statement, non_read_statement, join_not_supported, row_limit_exceeded, masked_column_in_filter.
Policy reference
The policy is default deny. See examples/policy.example.yaml.
version: 1
connection_env: DATABASE_URL
readonly: true # must be true in v0
defaults:
max_rows: 100
statement_timeout_ms: 5000
limit_mode: clamp # clamp or refuse
require_limit: false
allow:
schemas: [public]
tables:
- name: customers
columns: [id, name, created_at, billing_phone]
require_predicate: "deleted_at IS NULL"
- name: orders
columns: [id, customer_id, total_cents, status]
max_rows: 50
pii_mask:
patterns:
- column_regex: "(?i)email|phone|iban"
strategy: redact # redact, null or hashRules:
Allowlists. Only listed schemas, tables and columns can be named anywhere in a query: select list,
WHERE,GROUP BY,HAVING,ORDER BY.SELECT *expands to the allowed columns only. Unqualified table names resolve inallow.schemasorder, not through the databasesearch_path.Row limit. A missing
LIMITgets the cap. An over-largeLIMITis clamped (limit_mode: clamp, the default) or refused (refuse).require_limit: truerefuses queries with noLIMIT. Responses reportrow_limit,limit_clampedandmay_have_more. As a second layer, the server also truncates whatever the database returns to the cap.require_predicate. The expression is ANDed into every query on that table. Caller conditions are grouped first, so a callerORcannot escape it. Columns used in the predicate must be readable by the database role, but do not have to be in thecolumnslist.PII mask. Any output column that is, or is built from, a column whose name matches a
column_regexis masked, as is any output alias that matches. This holds forSELECT *, aliases and expressions such aslower(phone). Strategies:redact(replace with[REDACTED]),null, andhash(unsalted, truncated SHA-256; fine for de-duplication, weak for low-entropy values). Masked columns cannot be used inWHERE,GROUP BY,HAVINGorORDER BY, otherwise a caller could recover values by probing.Read only. Anything other than a single
SELECTis rejected by the guard, and queries run in aREAD ONLYtransaction. The database role must also lack write grants (below).deny_sql. Documents what is refused. The guard enforces these unconditionally, so removing an entry does not relax anything.
Policy names must match the real catalogue names. Unquoted Postgres identifiers are lower case.
What the guard refuses
Multi-statement input; everything that is not SELECT (INSERT, UPDATE, DELETE, DROP, TRUNCATE, COPY, SET, GRANT, DO, CALL, EXPLAIN, and so on); WITH, UNION, subqueries and joins (v0); FOR UPDATE; DISTINCT ON; window functions; unlisted functions (pg_sleep, pg_read_file, set_config, dblink and the rest); schema-qualified function calls; casts to reg* types; and anything the parser cannot understand. Unknown syntax is denied, not guessed at.
The callable function list is a short allowlist of aggregates, string, maths and date helpers. It is in src/guard.ts.
Audit log
One JSON object per line, written before any result is returned. If the log cannot be written the call fails and returns no data.
{"ts":"2026-10-07T02:17:31.231Z","policy_hash":"ff64ed87efa29a22","tool":"query","decision":"deny","reason":"column_not_allowed: column \"email\" is not allowed on public.customers","sql":"select email from customers","duration_ms":2}Fields: timestamp, tool, policy hash, decision, reason, normalised SQL, table, row count, duration. SQL is normalised before logging: string, dollar-quoted and numeric literals become ?, comments are removed, and whitespace is collapsed. Passwords and connection strings are never logged. Rotation is left to your platform (logrotate or similar) in v0.
Least-privilege role and RLS
The guard is the first line of defence. The database role is the second, and it should hold even if the guard has a bug. Create a dedicated role for the agent:
create role astraeus_agent login password '<set from your secret store>'
nosuperuser nocreatedb nocreaterole noinherit connection limit 4;
grant connect on database appdb to astraeus_agent;
grant usage on schema public to astraeus_agent;
-- Column-level SELECT only, matching the policy (plus any column the predicate uses).
grant select (id, name, billing_phone, created_at, deleted_at) on public.customers to astraeus_agent;
grant select (id, customer_id, total_cents, status) on public.orders to astraeus_agent;
-- No INSERT, UPDATE, DELETE, TRUNCATE, or DDL grants. Belt and braces:
alter role astraeus_agent set default_transaction_read_only = on;
alter role astraeus_agent set statement_timeout = '5s';Do not grant pg_read_all_data, ownership of any object, or membership of any other role.
For multi-tenant schemas, add row-level security so the boundary lives in the database, not only in a predicate:
alter table public.orders enable row level security;
alter table public.orders force row level security; -- also binds the table owner
create policy agent_reads_own_tenant on public.orders
for select to astraeus_agent
using (tenant_id = current_setting('app.tenant_id')::int);
-- Pin the tenant for this role, so the agent cannot choose it.
alter role astraeus_agent set app.tenant_id = '42';Recommendations for client installs:
Keep the connection string in a secret store or the MCP host's environment configuration. Never commit it, paste it into chat, or write it to the policy file.
Prefer a read replica DSN for agent traffic.
Require TLS (
sslmode=verify-full) when the database is not on the same host.One role and one policy file per client, so each audit log maps to one boundary.
Smoke test
Unit tests need no database. For an end-to-end check against a real Postgres, use the ephemeral container in docker-compose.yml. Pick throwaway values for the two passwords; they are never written to disk (the data lives in tmpfs).
export SMOKE_PG_ADMIN_PASSWORD='any-throwaway-value'
export SMOKE_PG_AGENT_PASSWORD='another-throwaway-value'
docker compose up -d --wait
npm run build
npm run smoke
docker compose downnpm run smoke drives the real server over stdio and checks that:
the role itself cannot read
customers.emailorsecrets, and cannot write;the row cap, PII redaction and
require_predicatehold;denied queries are denied (unlisted column, unlisted table, multiple statements,
DELETE, joins);the audit log has allow and deny entries and contains no phone numbers or passwords.
The seed script is smoke/init.sh and the policy is smoke/policy.smoke.yaml.
Development
npm ci
npm run lint
npm run typecheck
npm test
npm run buildTests mock the pg layer (Db interface in src/db.ts), so they run without a database. They cover multi-statement and non-SELECT rejection, table and column denial, row-cap enforcement, PII masking, audit on allow and deny, fail-closed startup, and an in-memory MCP round trip.
Layout:
src/policy.ts policy schema, loader, hash
src/guard.ts SQL parse, allowlist, rewrite
src/mask.ts PII masking
src/audit.ts JSONL audit log
src/db.ts pg access (read-only transaction)
src/tools.ts tool handlers
src/server.ts MCP wiring
src/startup.ts fail-closed startupLimits and honest caveats
A guard is not a sandbox. Treat the database role and RLS as the real boundary and the guard as a strong filter in front of it.
hashmasking is weak for low-entropy values such as phone numbers. Preferredactornull.Results can still be sensitive even when allowed. Keep column allowlists tight.
Error messages from Postgres are returned to the caller after trimming and connection-string scrubbing. Keep the role's visible schema small.
Audit rotation and shipping are not built in.
Roadmap
Not in v0, in rough order:
Joins across allowlisted tables, with the predicate applied per table and masks that survive aliases.
Named views in the policy that map to fixed SQL (a small semantic layer).
propose_writewith a human approval step. Never silent DML or DDL.Audit rotation.
Hosted Streamable HTTP with per-client keys. Out of scope for the local-first design.
Prior art
Studied for threat models and ergonomics, not copied: gokiwitech/pgwarden-mcp, microsoft/postgres-mcp, and the public postgres2mcp pattern.
Licence
MIT. See LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
Deterministic safety, correctness & cost gate that vets Postgres SQL before your AI agent runs it.
Query PostgreSQL databases in plain English — LLM-generated, safety-validated SQL.
Query 40 databases from Claude, ChatGPT, or Cursor — on any device. Read-only, encrypted, audited.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceProvides read-only access to PostgreSQL databases via MCP, enforcing least-privilege roles, row-level security, masked views, and SQL AST guardrails to prevent data leakage and unauthorized operations, enabling AI agents to safely query sensitive production data.MIT

Midplaneofficial
AlicenseNot gradedqualityAmaintenanceSQL guardrails for AI agents, sitting between the agent and Postgres to enforce policies, block destructive queries, and audit all access.5MIT- FlicenseNot gradedqualityCmaintenanceEnables AI agents to query a Postgres data warehouse through a governed, read-only SQL interface with policy enforcement, row limits, schema-level PII isolation, and a full audit trail.1-
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to safely query PostgreSQL by intercepting and auditing every generated SQL statement before it reaches the database, blocking destructive commands like DROP/TRUNCATE, unbounded DELETE/UPDATE statements, stacked queries, and SQL injection probes. It exposes tools for validating query safety and enforcing a strict read-only (SELECT/EXPLAIN) policy.MIT