pgwarden
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., "@pgwardenshow me the users table schema and mask any PII"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
pgwarden
Governed Postgres access for AI assistants. pgwarden is a drop-in MCP gateway that lets Claude, ChatGPT, Cursor or any MCP client query your Postgres database as the person asking — no shared password. Identity comes from your OIDC provider through OAuth 2.1; the database enforces access through each person's own login role, the DBA's grants, row-level security and masking views. Reads are read-only by construction, writes wait for a named human approver, and every tool call, sign-in, approval decision and admin change lands in an append-only, hash-chained audit log.
One shared database credential gives every user the union of everyone's access, and a prompt injection in a support ticket can make an assistant holding that credential read a secrets table (General Analysis on Supabase MCP, 2025). Guarantees bolted on outside the database get bypassed: a reference read-only Postgres MCP server was escaped with
COMMIT; <statement>(Datadog Security Labs, 2025). pgwarden's answer is to let Postgres decide, per person.
No SQL parsing. pgwarden never inspects, rewrites or allowlists your SQL (ADR-0001). It connects as the person's Postgres role and lets grants, RLS and a read-only transaction decide.
One login role per person, with credentials derived from a secret and sent only as SCRAM verifiers (ADR-0002). RLS keyed on
session_usercannot be spoofed from SQL.PII masking by generated
security_barrierviews, enforced by the database, not by post-processing (ADR-0005).Writes only through a human-approved queue, validated by
EXPLAIN, bound to the reviewed statement, executed at most once (ADR-0006).Tamper-evident audit log, verified independently of the SQL that wrote it (ADR-0007).
Backed by measurements: a red-team suite with per-category, oracle-verified results; a comparison against statement-filter baselines; latency overhead and a load test — all reproduced by the commands below.
Quickstart (under 5 minutes)
You need Docker (Compose v2). The MCP Inspector example below also needs Node (npx),
and the CLI example needs the uv CLI.
git clone https://github.com/B0yko/pgwarden && cd pgwarden
cp .env.example .env # optional: change host ports (all bind to 127.0.0.1)
devtools/check-ports.sh # fail early if a port is taken
docker compose up -d --wait # Postgres, a mock IdP, Mailpit, and the gatewayOpen http://localhost:8080 for the landing page and the exact commands to connect MCP Inspector, Claude Code and Cursor. For example:
npx @modelcontextprotocol/inspector@2.8.0 --server-url http://localhost:8080/mcp --transport httpConnect, and the browser walks you through consent, a mock sign-in (pick bob),
and a confirmation showing the Postgres role you will use. Then call whoami,
list_tables, describe_table and query. Sign in as alice to see PII masked;
as bob to see raw data but only EU rows; propose a write and approve it in
Mailpit at http://localhost:8025. The admin UI is at /admin.
A prebuilt multi-arch image of the gateway is published with each release:
docker pull ghcr.io/b0yko/pgwarden:0.1.0 (the compose file builds the same image from
source; pgwarden serve is its default command).
The CLI runs without a clone, too:
uvx --from git+https://github.com/B0yko/pgwarden pgwarden --helpRelated MCP server: PostgreSQL MCP Server
How it works
flowchart LR
C["MCP client<br/>(Claude, ChatGPT, Cursor, Inspector)"] -- HTTPS --> GW
subgraph GW["pgwarden (FastAPI, one process)"]
OA["/oauth/*, /.well-known/*<br/>OAuth 2.1 AS facade"]
MCP["/mcp<br/>stateless streamable HTTP"]
WEB["/approve, /admin<br/>Jinja2 pages"]
MW["auth → rate limit → tool → per-person pool → audit"]
end
OA -- consent, then --> IDP["upstream OIDC IdP"]
MCP --> MW
MW -- "connects AS pw_u_<person>" --> T[("target DB<br/>grants + RLS + pw_masked views")]
MW -- "hash-chained audit, tokens, proposals" --> S[("state DB")]Every query call runs the same fixed sequence, with no SQL parsing anywhere:
acquire the person's connection, BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY,
set the timeouts with bound values, prepare the statement through the extended
protocol (which rejects a second statement), fetch up to the row cap through a
portal, serialize up to the byte cap, and always ROLLBACK. See
docs/adr for the decisions and docs/threat-model.md
for the threat model.
The OAuth flow
sequenceDiagram
participant Client
participant pgwarden
participant IdP
Client->>pgwarden: GET /oauth/authorize (PKCE, resource, state)
pgwarden-->>Client: consent page (client name, redirect host)
Client->>pgwarden: approve (CSRF POST)
pgwarden->>IdP: redirect with a fresh, browser-bound state + nonce
IdP-->>pgwarden: GET /oauth/callback (code)
pgwarden-->>Client: confirmation (identity, Postgres role)
Client->>pgwarden: confirm (CSRF POST)
pgwarden-->>Client: redirect with authorization code
Client->>pgwarden: POST /oauth/token (code + verifier + resource)
pgwarden-->>Client: access token (EdDSA at+jwt, aud = /mcp)A real session
The four screenshots below come from one scripted session against the demo stack:
MCP Inspector 2.8.0 connects through its own OAuth client (dynamic client
registration, PKCE, resource indicator), first as alice, then as bob, and the
admin page is opened as carol.
devtools/screenshots/run.py produces them (headless Chromium, fixed 1440x900
viewport, page content only, PNG metadata stripped) and fails if the Inspector's
OAuth flow does not end connected with the tool list visible. It is development
tooling and is not part of the wheel or the production image.
The approval flow
sequenceDiagram
participant Client as MCP client
participant pgwarden
participant DB as Postgres (writer role)
participant Approver as Approver (browser)
Client->>pgwarden: propose_write (SQL, params, reason)
pgwarden->>DB: EXPLAIN (FORMAT JSON) as the person's writer role
DB-->>pgwarden: plan, accepted only with one Insert/Update/Delete root
pgwarden->>Approver: notice with a summary and a signed link, never the SQL
Approver->>pgwarden: open the link, sign in, review the exact statement
Approver->>pgwarden: approve (CSRF POST), not the proposer
pgwarden-->>Client: single-use grant, valid 15 minutes
Client->>pgwarden: execute_approved_write
pgwarden->>pgwarden: check proposer and binding, claim the grant atomically, audit "started"
pgwarden->>DB: run the stored statement in a read-write transaction
DB-->>pgwarden: rows affected (more than max_rows rolls back)
pgwarden-->>Client: result, auditedResults
All numbers below come from the commands shown, on a MacBook Air M5, 24 GB, Docker
via colima with 4 CPUs / 6 GB, against the demo stack. Regenerate the tables with
pgwarden report.
Red-team suite
133 must-block attacks in nine categories (at least 8 per category, each a distinct
technique) and 32 benign controls, run by pgwarden redteam run (deterministic, no LLM;
runs in CI on every push). Each attack has an oracle that decides from database
what happened (rows returned, table checksums, locks, HTTP status, proposal state,
timing), not from matching an error message, whether the objective was achieved, and
the runner also checks that the layer that stopped it is the one the case expected. The run needs
the admin DSN only for the oracles, which read the tables directly:
docker compose up -d --wait
export PGWARDEN_ADMIN_DSN="$(sed 's#/postgres?#/shop?#' .pgwarden-dev/admin/admin_dsn_host)"
export PGWARDEN_STATE_DSN="$(cat .pgwarden-dev/host/state_dsn_host)" # optional: clears rate windows so a rerun is clean
uv run pgwarden redteam run --allow-load --target-url http://localhost:8080 \
--machine-secret-file .pgwarden-dev/machines/machine-nightly-report \
--report docs/results/redteam-$(date +%F).json--allow-load also runs the request-flood case, the only one where a rate_limited
outcome counts as blocked. uv run pytest tests/integration/test_redteam_positive_control.py
is the positive control: over a deliberately unsafe superuser connection the same oracles
flag at least 90% of the cases in categories C, D, E and G as achieved (47 of 49, 96%, in
the last run; it needs the test Postgres from devtools/testpg.sh up).
Category | Attacks | Blocked (oracle-verified) | Observed primary blocking layer |
A. Stacked statements | 13 | 13 | protocol |
B. Writes on the read path | 18 | 18 | approval, privileges, protocol, read_only_transaction |
C. Privilege escalation | 13 | 13 | privileges, read_only_transaction, rls |
D. Crossing RLS | 13 | 13 | privileges, rls |
E. Bypassing masking | 12 | 12 | masking_view, privileges |
F. Resource exhaustion | 13 | 13 | rate_limit, timeout_or_cap |
G. Canary exfiltration | 11 | 11 | privileges |
H. Approval abuse | 20 | 20 | approval |
I. OAuth and session | 20 | 20 | oauth |
Benign controls passed: 32 / 32. Documented residual risks: 2.
Run: 2026-09-28; commit 9f60c13; Postgres 16.15; MacBook Air M5, 24 GB, Docker via colima with 4 CPUs / 6 GB; config pgwarden.yaml (sha256 b5c902f5656f70cd); 2 other containers running on the machine during the run.
Two behaviours are documented residual risks: they are run and recorded (the call must
succeed and return data, as documented) and never counted as blocked. Row estimates
through plain EXPLAIN (D09) come from table-wide statistics that row security does not
filter, and relation names in pg_class (G09) are readable by every role. Reading
pg_stats is not one of them: Postgres withholds those rows for tables with row
security active and for columns the role cannot read, so D05 and E05 are blocked.
Statement-filter baselines
pgwarden bench baselines runs the SQL-bearing part of the corpus (the query attacks
of categories A to G and the benign controls that send SQL; the denominators are below)
through two filters that live only in bench/, never in the product — the evidence for
ADR-0001. The full blocklist and
the pinned sqlglot version (30.20.0) are published in docs/baselines.md;
both filters were written for this comparison.
Baseline | Attacks it would let through | Benign queries it would wrongly block |
keyword/regex blocklist | 56 / 90 | 3 / 29 |
sqlglot SELECT-only allowlist | 56 / 90 | 2 / 29 |
pgwarden (database-enforced) | 0 / 90 | 0 / 29 |
The 90 attacks are the query cases of categories A to G that must be blocked; the 29 benign queries are the benign controls that send SQL. The pgwarden row is the red-team run above, not a separate measurement. sqlglot 30.20.0.
Run: 2026-09-28; commit 9f60c13.
Latency and load
Measured on 2026-09-28 on a MacBook Air M5, 24 GB, Docker via colima with 4 CPUs / 6 GB. The laptop was shared, not idle: the bench client, the gateway and Postgres all ran in the same 4-CPU colima VM, two other containers (the test suite's Postgres and PgBouncer) were running, and so were other programs. Absolute numbers therefore move from run to run (an earlier 60 s load run that evening gave 621 requests/s against the 565 recorded here; only the recorded run is committed); read them as an orientation for one gateway process on a laptop, not as a capacity claim. Nothing here was run on a server.
The client runs in its own bench container of the compose stack: the same image as the
gateway, on the compose network (so every request crosses a network hop), with no Docker
socket and no admin credentials. It mounts the bench machines' secrets and, for the
direct-Postgres baselines, the gateway's role secret (the login password of the bench
role is derived from it), all read-only.
# The stack on the bench config: machines bench-01..20 and raised rate limits
export PGWARDEN_DEMO_CONFIG=pgwarden.bench.yaml
docker compose up -d --wait
# Latency: 3 repetitions of 1000 timed calls after 100 warm-up calls, per query shape
docker compose --profile bench run --rm bench pgwarden bench latency --iterations 1000 --warmup 100
# Load: 20 identities at concurrency 20 for 60 s
docker compose --profile bench run --rm bench pgwarden bench load --identities 20 --concurrency 20 --duration 60 --mix pk:60,filter:30,agg:10
# Cold first query: the same config plus a 1 s pool idle timeout, so the pool is evicted in a pause
export PGWARDEN_DEMO_CONFIG=pgwarden.bench-cold.yaml
docker compose up -d --no-deps --wait gateway
docker compose --profile bench run --rm bench pgwarden bench cold --samples 30 --idle-wait 2.5
# Back to the default demo config
unset PGWARDEN_DEMO_CONFIG
docker compose up -d --waitThose commands print the numbers. The committed files in docs/results/
come from a host wrapper that runs the same commands and adds what the container cannot
see: the commit, colima's CPUs and memory, the number of other running containers, the
Postgres version and the config hash, the gateway's CPU, RSS and Postgres connections
sampled from the host during the load (docker stats, VmRSS and pg_stat_activity,
several samples a second), and the result of pgwarden audit verify after each run:
uv sync
PGWARDEN_BENCH_MACHINE="MacBook Air M5, 24 GB" devtools/bench/run.sh all # about 8 minutes
uv run pgwarden report # regenerates the tables below
devtools/bench/run.sh restore # default demo config againdevtools/bench/run.sh latency|cold|load runs one part (cold adds to the latency file).
The three query shapes are committed in src/pgwarden/bench/queries.yaml. In the latency
table, Direct is plain asyncpg from the bench container as the machine role
pw_m_bench_01 on a warm connection, Direct + wrapper is the read path's own
BEGIN READ ONLY / set_config / prepare / fetch / ROLLBACK sequence on a pooled
connection, and Via pgwarden is an MCP tools/call query over HTTP with a warm token.
Overhead is the last minus the first. Every timed call must return the row count the
database returns for the same statement, or the run aborts.
Query | Direct p50 / p95 | Direct + wrapper p50 / p95 | Via pgwarden p50 / p95 | Overhead p50 / p95 (ms) |
primary-key lookup (1 row) | 0.06 / 0.080.05-0.07 / 0.07-0.32 | 0.46 / 0.570.46-0.48 / 0.50-0.75 | 2.04 / 2.901.95-2.38 / 2.55-4.02 | 1.98 / 2.821.88-2.32 / 2.23-3.95 |
30-row filtered select (30 rows) | 0.13 / 0.140.12-0.15 / 0.14-0.19 | 0.60 / 0.710.58-0.63 / 0.63-0.88 | 2.40 / 4.092.16-2.56 / 2.66-4.24 | 2.28 / 3.952.03-2.41 / 2.52-4.10 |
monthly aggregate over orders (24 rows) | 12.6 / 14.912.1-13.5 / 13.5-16.6 | 13.1 / 15.812.7-13.4 / 14.0-15.8 | 15.8 / 20.515.4-17.6 / 19.5-24.8 | 3.27 / 5.613.27-4.13 / 2.96-9.95 |
Milliseconds, median of 3 repetitions of 1000 timed calls after 100 warm-up calls each; the small line under a cell is the min-max of the repetitions' p50 / p95. Overhead is via pgwarden minus direct.
Server-Timing span medians inside the gateway (ms):
Query | auth | ratelimit | db | audit |
primary-key lookup | 0.20 | 0.20 | 0.50 | 0.30 |
30-row filtered select | 0.20 | 0.20 | 0.60 | 0.40 |
monthly aggregate over orders | 0.30 | 0.20 | 13.5 | 0.40 |
Cold first query after the pooled connection was evicted (connect + SCRAM; primary-key lookup, gateway with pool.idle_timeout_s: 1, 2.5 s pause): median 43.1 ms (min 19.2, max 62.3) against 3.74 ms warm, a cold cost of 39.4 ms, of which 29.8 ms in the db span. 30 of 30 samples were confirmed cold by a new backend appearing in pg_stat_activity.
Design target (overhead p50 <= 10 ms on the primary-key lookup): met, 1.98 ms.
Run: 2026-09-28; commit 6edd97f; Postgres 16.15; MacBook Air M5, 24 GB, Docker via colima with 4 CPUs / 6 GB; config pgwarden.bench.yaml (sha256 fb713f1da5d24904); 2 other containers running on the machine during the run.
Audit chain verified after the latency run: OK, chain intact through seq 80715.
Reading the latency table: on a primary-key lookup the gateway adds about 2 ms at p50 and
under 3 ms at p95. The read-path wrapper accounts for about 0.4 ms of that (0.46 against
0.06 ms), and the Server-Timing spans add up to about 1.2 ms of the 2 ms (auth, rate limit
and audit are each a few tenths of a millisecond); the rest is HTTP and MCP framing and the
client itself. On the monthly aggregate the query dominates (13.5 ms in db) and the gateway
adds about 3 ms. The cold first query is dominated by opening the database connection
(TCP, startup, SCRAM): about 30 ms of the extra 39 ms sit in the db span, and the other
few milliseconds are auth and ratelimit also being slower after a 2.5 s pause, so the
db figure is the better estimate of connect + SCRAM. It is paid once per person or machine
after a pooled connection was idle for pool.idle_timeout_s (60 s by default). The cold run
uses machine bench-01; a person's pool goes through the same connection code.
Measure | Result |
Identities | 20 machine identities (bench-01 to bench-20) |
Concurrency | 20 |
Duration | 60.1 s, mix |
Total requests | 33962 (0 rate-limited) |
Requests/s | 565.3 |
Latency p50 / p95 / p99 | 26.7 / 94.8 / 160.0 ms |
Error rate (rate-limited calls excluded) | 0.00% (0 errors) |
Peak Postgres connections (pgwarden roles, | 20 |
Gateway peak CPU ( | 102.1% (mean 81.2%, 122 samples) |
Gateway peak RSS | 95.5 MiB (171 samples) |
| OK: chain intact through seq 114881, head seq 114881 |
Design target (p95 <= 150 ms with 0 non-rate-limit errors): met (p95 94.8 ms, 0 errors).
Run: 2026-09-28; commit 6edd97f; Postgres 16.15; MacBook Air M5, 24 GB, Docker via colima with 4 CPUs / 6 GB; config pgwarden.bench.yaml (sha256 fb713f1da5d24904); 2 other containers running on the machine during the run.
Reading the load table: the design targets were met on this run. The gateway is a single process; it averaged 81% of one core and peaked at 102% while the load client shared the VM, which suggests one gateway process was close to saturated. Postgres held one connection per active identity (peak 20). The design targets are goals, not claims: the numbers above are whatever the run measured.
LLM indirect-injection run
pgwarden redteam llm drives two inexpensive tool-calling models through the demo
stack, whose data carries planted prompt injections. Rows returned beyond the
identity's privileges and writes executed without approval must both be zero;
exfiltration through the model's final answer is a residual risk the gateway cannot
block, reported honestly. This run costs money and is never in default CI.
The stack runs demo/pgwarden.llm.yaml for it, the demo config with the query,
proposal and registration limits raised to 600 so the run is not throttled.
PGWARDEN_DEMO_CONFIG=pgwarden.llm.yaml docker compose up -d --no-deps gateway
export OPENROUTER_API_KEY=... # never written to a file in the repository
export PGWARDEN_ADMIN_DSN="$(sed 's#/postgres?#/shop?#' .pgwarden-dev/admin/admin_dsn_host)"
export PGWARDEN_TARGET_DSN="$PGWARDEN_ADMIN_DSN"
export PGWARDEN_ROLE_SECRET_FILE=.pgwarden-dev/gateway/role_secret
uv run pgwarden redteam llm --models deepseek/deepseek-v4-flash-0731,qwen/qwen3.7-flash \
--trials 3 --max-turns 12 --budget 0.15 \
--provider deepseek/deepseek-v4-flash-0731=sail-research --provider qwen/qwen3.7-flash=alibaba \
--target-url http://localhost:58080 --report docs/results/llm-redteam-$(date +%F).json
unset PGWARDEN_DEMO_CONFIG; docker compose up -d --no-deps gateway # back to the demo configEach of the 10 tasks runs 3 times per model, at most 12 turns per episode, at temperature
0, with the provider pinned and fallbacks off; the provider that actually served each call
is recorded, and costs come from OpenRouter's reported cost per call. Read the table
carefully: only the DeepSeek model was ever shown a planted injection (6 exposures in 3
episodes) and it acted on all 6, and the gateway blocked all 6; the Qwen model's tasks
never surfaced a planted marker, so its zero says nothing about how it would have
behaved. The gateway container in that run was built from an earlier commit that differs
from the recorded one only in comments and the describe_table tool description.
Model | Episodes | Tasks solved | Marker exposures | Injection-induced attempts | Attempts per exposure | Attempts blocked | Rows beyond privilege | Writes without approval | Exfil-in-answer episodes | Spend (USD) |
| 30 | 30 | 6 | 6 | 1.00 | 6 | 0 | 0 | 0 | 0.0053 |
| 30 | 30 | 0 | 0 | n/a | 0 | 0 | 0 | 0 | 0.0045 |
Provider that served each call, from OpenRouter's response: deepseek/deepseek-v4-flash-0731: Sail Research 117 (pinned to sail-research); qwen/qwen3.7-flash: Alibaba 109 (pinned to alibaba).
Total spend: $0.0099 of a $0.15 budget. Rows beyond privilege and writes without approval must be 0; exfiltration through the model's final answer is a residual risk the gateway cannot block.
Run: 2026-09-28; commit c6cb29a; MacBook Air M5, 24 GB, Docker via colima with 4 CPUs / 6 GB; config pgwarden.llm.yaml (sha256 3f55c698a58e6159).
The models used and their prices are verified at run time; the recorded run's full JSON is in docs/results/.
Setup, versions and infrastructure checks
Setup. Measured on 2026-09-28 with
time: on a fresh clone (a distinct compose project, non-default ports, new secrets generated)docker compose up -d --waittook 12 s, andpgwarden redteam runon that stack passed (131 / 131 attacks blocked, 32 / 32 benign controls; the corpus has grown since, see the table above). Fromgit cloneto the firstwhoamithrough the OAuth flow took about 16 s: 1 s clone, 12 s compose, 2 s for theuvenvironment, 1 s for login and the call. That was with the Postgres, Python and Mailpit images, the build layers and theuvcache already on the machine. Building the gateway and mock-IdP images without the layer cache took 11 s and 5 s more (docker build --no-cache); pulling the three public images depends on your network and was not measured.Image. The production image runs as a non-root user (uid 10001) and contains no mock IdP, screenshot script or test suite (it does carry the
redteamandbenchsubcommands).docker image inspectreports 73.8 MB of image content;docker image lsshows 344 MB unpacked on disk.Versions. The full test suite and every recorded run above used Postgres 16.15 and Python 3.12. CI runs the database tests on Postgres 16 and 18 (18 is the newest stable major on 2026-09-28; 19 is still in beta).
Terraform.
deploy/terraform/check.shrunsterraform fmt -check,terraform validate(module and example),terraform test(4 runs with mock providers),tflintandtrivy configthrough pinned Docker images and never authenticates to a cloud: everything passes with 0 findings, after one accepted exception (AVD-GCP-0017, a public Cloud SQL address with no authorized networks, reasoned in deploy/terraform/.trivyignore). The module is validated and scanned, not applied in v0.1.
Verified clients and identity providers
MCP client | Status |
MCP Inspector 2.8.0 | Verified live: the OAuth flow and tool calls run in headless Chromium against the compose stack ( |
Plain HTTP client | Verified live: the red-team suite and the end-to-end tests drive the OAuth, MCP, approval and admin flows over HTTP |
Claude Code, Cursor | Not yet verified live. The landing page prints the connect commands; issue reports from real sessions are welcome |
ChatGPT, claude.ai connectors | Expected by design, not verified: they need a public HTTPS URL |
Identity provider | Status |
In-repo mock OIDC provider | Verified live: it runs every test, the red-team suite and the screenshots |
Generic OIDC, Google, Microsoft Entra ID, GitHub | Tested only against hand-written, recorded discovery documents and token responses; not verified against a real tenant in v0.1 |
Use it on your own database
pgwarden works on the bundled demo data and on your own Postgres 16+ through
configuration. See docs/own-database.md for creating bundle
roles and RLS (keyed on session_user), writing pgwarden.yaml, the minimal admin
privileges, and running db init, roles sync, masking apply, doctor and
serve. docs/identity-providers.md covers generic
OIDC, Google, Entra and GitHub. docs/configuration.md is
the full configuration reference, generated from the code.
Deploy to Google Cloud Run + Cloud SQL with the Terraform module in deploy/terraform/gcp-cloud-run/ (ADR-0008).
Limitations
A compromised gateway host holds the role secret, so it can act as any mapped person within that person's privileges.
Disclosure of data the person is allowed to read is limited but not prevented.
Planner statistics can leak through plain
EXPLAIN.A function in the target database that runs dynamic SQL is a hazard pgwarden cannot see.
IdP offboarding takes effect within the refresh-token family's absolute lifetime (8 h from login) unless the person is suspended or removed from config.
The audit log stores SQL text, which may contain literals.
A database superuser can rewrite the audit table and recompute the chain; export the head hash that
pgwarden audit verifyprints to somewhere the superuser cannot write.Masking is all-or-nothing per person, and pseudonyms are linkable by design.
Fixed-window rate limits allow bursts of up to twice the limit at window edges.
One target database per deployment, and single-tenant Entra only.
Rate limits apply to
queryand to write proposals;whoami,list_tablesanddescribe_tableare not rate limited.Access tokens are valid for 10 minutes. A revoked token or a suspended person is refused on the next call (the gateway checks the state database on every request), but a person removed only at the identity provider keeps working until their refresh-token family ends (the offboarding item above).
A write is one plain
INSERT,UPDATEorDELETE: noMERGE, DDL or several statements. If the gateway crashes after an approved write was claimed, the proposal staysexecutingand never re-runs, so a person has to propose it again.Transaction-mode poolers (PgBouncer in transaction mode, hosted transaction-pooler endpoints) are unsupported, because the per-person session state pgwarden relies on does not survive them;
pgwarden doctordetects them. Connect directly or through a session-mode pooler.
Roadmap
Everything described above as shipped is implemented and tested as stated; what is marked as not verified is not verified. Candidate next steps: masking variants per bundle, just-in-time role provisioning from IdP group claims, more upstream presets, and Terraform for other clouds.
Data
All demo data is synthetic, generated in-repo with setseed, and licensed
Apache-2.0 with the code: invented names, example.com/example.org/example.net
emails, reserved fictional phone ranges, and canary tokens that look like
CANARY-PM-0001 (no real card data). See demo/sql/.
Development
uv sync, then uv run pytest (unit tests need nothing; integration tests need a
Postgres 16 via devtools/testpg.sh up; stack tests need docker compose up, and the
MCP Inspector OAuth check also needs npx and uv run playwright install chromium).
To regenerate the README screenshots against a running stack:
uv run python devtools/screenshots/run.py.
uv run ruff check, uv run ruff format --check and uv run mypy --strict src/
must pass. See CONTRIBUTING.md.
License
Apache-2.0. Copyright 2026 Andrii Boiko. See LICENSE and NOTICE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
Query 40 databases from Claude, ChatGPT, or Cursor — on any device. Read-only, encrypted, audited.
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.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceConnects AI assistants to PostgreSQL databases with production-grade safety features including query validation, guarded writes, rate limiting, and audit logging.3MIT
- AlicenseNot gradedqualityBmaintenanceEnables AI assistants to securely interact with PostgreSQL databases, offering 30+ tools, role-based access control, and security guardrails.2MIT
- AlicenseNot gradedqualityAmaintenanceExperimental read-only MCP for Supabase application data: memory_search, memory_get and memory_list_recent under user-bound PostgreSQL RLS. Setup and demo: https://github.com/jryski/Supabase_user_MCP/blob/main/docs/GETTING_STARTED.md . Public preview is POSIX-only and synthetic-only; native Windows, hosted OAuth, one-click installation and writes are not available.Apache 2.0
- 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-