pg_mcp_qauth
Allows using Auth0 as the OAuth 2.1 / OIDC Authorization Server that issues access tokens validated by the server to control PostgreSQL access.
Allows using Authentik as the OAuth 2.1 / OIDC Authorization Server that issues access tokens validated by the server to control PostgreSQL access.
Allows using Keycloak as the OAuth 2.1 / OIDC Authorization Server, including browser-based PKCE flows and identity brokering, to authenticate users and authorize PostgreSQL access.
Allows using Okta as the OAuth 2.1 / OIDC Authorization Server that issues access tokens validated by the server to control PostgreSQL access.
Allows executing read-only SQL queries against PostgreSQL, with schema-, table-, and row-level access control through database roles and Row Level Security.
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., "@pg_mcp_qauthShow me the top 5 customers by revenue"
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.
pg_mcp_qauth
A Postgres MCP server (Model Context Protocol) with OAuth 2.1 authorization for Claude Code, VS Code and any other MCP client. The server acts as an OAuth Resource Server: it validates JWTs issued by an external Authorization Server (Keycloak / Authentik) and executes only safe read-only queries against PostgreSQL, with privileges isolated by database roles and Row Level Security.
Status: beta. Verified end-to-end against a real Keycloak 26 + Postgres 16, including browser-based OAuth (PKCE) flows from VS Code.
Features: OAuth 2.1 resource server (RFC 9728) · multi-provider login via AS brokering (Google / Entra ID / AD / Okta / GitHub) · three-tier access model (schema → table → row/RLS) · AST-based SQL guard · audit logging · health endpoint · non-root Docker image.
🇷🇺 Русская версия документации: README.ru.md
Architecture
MCP client (Claude Code, VS Code, …) — an OAuth 2.1 client: obtains an access token from the Authorization Server using PKCE.
Keycloak / Authentik — the single Authorization Server and identity broker. Multi-provider login (Google, Microsoft Entra ID, on-prem AD, Okta, GitHub, …) is configured via identity brokering on the AS side; the server code always validates one issuer.
pg_mcp_qauth — Resource Server (FastMCP, Streamable HTTP): serves RFC 9728 Protected Resource Metadata, returns
401 + WWW-Authenticatewithout a token, and validates signature /iss/aud/expvia JWKS.PostgreSQL — every query runs in a
READ ONLYtransaction underSET LOCAL ROLE <role>with a session GUC for RLS.
MCP client --(OAuth PKCE)--> Keycloak/Authentik --(JWT)--> pg_mcp_qauth --> Postgres
| brokering
+-> Google / Entra ID / AD / Okta / GitHub ...Related MCP server: readonly-postgres-mcp
Access model (three tiers)
Tier | Mechanism | Who configures |
Schema |
| DBA |
Table |
| DBA |
Row | RLS driven by a session GUC | DBA |
JWT group claims (ROLES_CLAIM) are mapped to a DB role through ROLE_MAP_JSON
(otherwise DEFAULT_ROLE applies). If a user belongs to several mapped groups,
precedence follows the declaration order of keys in ROLE_MAP_JSON (first match
wins), not the order of groups in the token. The role decides which tables are
visible; RLS decides which rows a specific user can see.
Keycloak: roles live in nested claims (
realm_access.roles/resource_access.<client>.roles) by default. The server reads a flat claim, so configure a protocol mapper that emits groups/roles into a flat claim (e.g.groups) and pointROLES_CLAIMat it.
list_tables, describe_table and run_query all execute under SET LOCAL ROLE and
additionally check has_table_privilege(..., 'SELECT') — the structure of tables the
role cannot access is never disclosed.
DBA runbook (example)
-- 1. Pool login user: NOT a superuser, NOT the table owner.
CREATE ROLE mcp_gateway LOGIN PASSWORD 'change-me';
-- 2. Privilege-bearing roles.
CREATE ROLE read_analyst NOLOGIN;
CREATE ROLE read_marketing NOLOGIN;
-- 3. mcp_gateway must be able to SET ROLE to the target roles.
GRANT read_analyst, read_marketing TO mcp_gateway;
-- 4. Schema / tables.
GRANT USAGE ON SCHEMA analytics TO read_analyst;
GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO read_analyst;
ALTER DEFAULT PRIVILEGES IN SCHEMA analytics GRANT SELECT ON TABLES TO read_analyst;
-- 5. Row Level Security driven by a session variable the MCP server sets.
ALTER TABLE analytics.orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics.orders FORCE ROW LEVEL SECURITY;
CREATE POLICY orders_by_region ON analytics.orders
USING (region = current_setting('app.user_email', true));
current_setting('app.user_email', true)/app.groupsare set by the server viaset_config(..., true)inside the transaction and never leak into pooled connections.
Quickstart
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]" # or: pip install -r requirements.lock for exact versions
cp .env.example .env # fill in values
python -m pg_mcp_qauth.serverDocker (installs runtime deps from the pinned requirements.lock;
regenerate with pip-compile pyproject.toml -o requirements.lock --strip-extras):
docker build -t pg_mcp_qauth .
docker run --env-file .env -p 8000:8000 pg_mcp_qauthConfiguration
See .env.example. Key parameters:
AUTH_ISSUER,JWKS_URI,REQUIRED_AUDIENCE,ALGORITHMS— token validation.REQUIRED_AUDIENCEis mandatory to prevent token passthrough.ROLES_CLAIM,RLS_USER_CLAIM— which JWT claims to read.ROLE_MAP_JSON,DEFAULT_ROLE— group → DB role mapping.PG_*— database connection (PG_USERrequirements: see the runbook above).MAX_ROWS,STATEMENT_TIMEOUT_MS,POOL_*,ALLOW_SYSTEM_SCHEMAS.
Auth configuration is hard-validated at startup (Settings.check()):
REQUIRED_AUDIENCE is required (an unchecked aud claim enables token passthrough)
and ALGORITHMS must be exactly one asymmetric algorithm (HS*/none are rejected).
MCP_BASE_URL participates in RFC 9728 resource metadata — behind a reverse proxy, set
the externally reachable URL. /health executes a real SELECT 1 (503 when the DB is
down).
Supported Authorization Servers
The server validates exactly one issuer — any OAuth 2.1 / OIDC Authorization Server that can meet these requirements works:
JWKS endpoint with an asymmetric signing algorithm (RS*/ES*/PS*/EdDSA, pinned in
ALGORITHMS);access tokens with a stable
audmatchingREQUIRED_AUDIENCE;a flat claim carrying user groups/roles (via protocol mappers / claims policies) for
ROLES_CLAIM, and the identity claim used for RLS (RLS_USER_CLAIM).
Setup | Examples | Notes |
Self-hosted AS (recommended) | Keycloak, Authentik, Zitadel | full control over mappers; identity brokering gives social/corporate logins |
Cloud AS directly | Okta, Auth0, Microsoft Entra ID | Entra: configure |
Logins via AS brokering | Google, GitHub, Microsoft accounts, AD FS / on-prem AD, any OIDC/SAML | users authenticate upstream; your AS remains the single issuer |
Not usable directly | bare "Sign in with Google" | Google ID tokens lack groups and custom |
users -> your AS (Keycloak/Authentik/…) --brokering--> Google / Microsoft / GitHub / AD / …
|
+-- issues JWT (single issuer) --> pg_mcp_qauthConnecting MCP clients
Because the server publishes RFC 9728 metadata (advertised in the 401
WWW-Authenticate: Bearer resource_metadata="…" challenge), OAuth-capable clients
discover the Authorization Server automatically and open the browser sign-in themselves.
VS Code (.vscode/mcp.json) — browser sign-in via your Keycloak realm:
{
"servers": {
"pg_mcp_qauth": {
"type": "http",
"url": "http://localhost:8000/mcp",
"oauth": { "clientId": "pg-mcp" }
}
}
}Set standardFlowEnabled on the client and configure its redirect URIs to whatever the
client uses (VS Code/Claude Code register http://127.0.0.1:<port>/callback style URIs;
a * pattern is acceptable for local testing only).
Claude Code — the same OAuth discovery, or a static token for scripted/CI use:
claude mcp add --transport http pgmcp https://mcp.example.com/mcp \
--header "Authorization: Bearer $ACCESS_TOKEN"DCR caveat: with Dynamic Client Registration the AS issues tokens for a dynamically created client, which usually lacks your audience/group protocol mappers. Pin the client with
oauth.clientId(or your AS's equivalent) so issued tokens keep the claims the access model depends on.
Security (SQL guard)
run_query accepts a single SELECT only (including WITH … SELECT and set
operations), validated by parsing into an AST (sqlglot, postgres dialect):
DML/DDL (
INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/TRUNCATE/GRANT), multi-statement input,SET/COMMITetc. are rejected;a denylist of dangerous functions is blocked (
pg_sleep,pg_read_file,lo_import,dblink,set_config,nextval, advisory lockspg_advisory_lock/pg_try_advisory_lock, …);system schemas (
pg_catalog,information_schema) are closed unless explicitly allowed;results are capped at
MAX_ROWS(auto-LIMIT);round-trip integrity: since the re-serialized SQL is what executes, the guard compares the operator-token multiset before/after re-serialization and explicitly rejects queries the parser could silently mangle (e.g. the ParadeDB
@@@operator, which sqlglot turns into@@ $'..'). Plain@@ to_tsquery(...)works fine.
Defense in depth (recommended for the runbook)
The function denylist is incomplete by nature, so the primary guarantees come from
Postgres itself: PG_USER is neither superuser nor table owner, transactions are
READ ONLY, target roles have no CREATE. Additionally revoke execution of dangerous
functions from PUBLIC:
REVOKE EXECUTE ON FUNCTION
pg_sleep(float8), pg_sleep_for(interval), pg_sleep_until(timestamptz),
pg_read_file(text), pg_ls_dir(text), lo_import(text), lo_export(oid, text)
FROM PUBLIC;MCP tools
list_tables()— tables/views visible to the current role.describe_table(table_name)— columns, types, nullability, comments (catalog read).run_query(sql)— safe read-only query.
Dry-run permission preview (no server needed)
pg-mcp-preview shows what a token resolves to on the access side: the matched group,
the DB role, the mapping source and — with --check-db — the real list of visible
tables under that role. JWT signatures are not verified: this debugs
ROLE_MAP_JSON, not auth.
echo "$ACCESS_TOKEN" | pg-mcp-preview - # mapping only
echo "$ACCESS_TOKEN" | pg-mcp-preview - --check-db # + visible tables from the DBTesting
Unit tests for the SQL guard and config validation (no DB required):
pytestIntegration end-to-end run (real Postgres + locally signed JWTs + local JWKS + a live MCP client over Streamable HTTP):
docker run -d --name pgmcp_test -e POSTGRES_DB=analytics \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 55432:5432 \
-v "$PWD/integration/init.sql:/docker-entrypoint-initdb.d/init.sql:ro" postgres:16-alpine
IT_PG_PORT=55432 python integration/run_integration.py
docker rm -f pgmcp_testExercised: JWT authorization (iss/aud/signature via JWKS), group→role mapping and
SET LOCAL ROLE (schema/table isolation via GRANTs), per-row RLS (same role, different
users → different rows), DML and system-schema rejection, catalog reads. For production
it is enough to point AUTH_ISSUER/JWKS_URI at a real Keycloak/Authentik — no code
changes.
A "combat" run against a real Keycloak (actual OAuth token endpoint, JWKS, groups,
audience mapper) lives in integration/run_keycloak.py; see CONTRIBUTING.md.
Manual-test data: DVD Rental + pg_search
The integration fixture (integration/init.sql) is synthetic (4 rows), so for join /
aggregate / full-text-search experiments a real dataset is more convenient. No auth
plumbing (Keycloak, roles, RLS) is needed here — just Postgres with data.
curl -sL -o /tmp/dvdrental.tar \
https://raw.githubusercontent.com/ferry8/dvdrental.tar/master/dvdrental.tar # ~2.8 MB, pg_dump -F c
docker run -d --name pgmcp_sample -e POSTGRES_DB=analytics \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 55432:5432 \
paradedb/paradedb:0.25.10-pg17
docker exec -i pgmcp_sample pg_restore -U postgres -d analytics --no-owner < /tmp/dvdrental.tar
docker rm -f pgmcp_sample # remove when done (data lives in an anonymous volume)Connect: localhost:55432, database analytics, postgres/postgres. The image ships
pg_search (BM25/Tantivy, extension pre-installed):
CREATE INDEX film_bm25 ON film USING bm25 (film_id, title, description)
WITH (key_field='film_id',
text_fields='{"title":{"tokenizer":{"type":"icu"}},"description":{"tokenizer":{"type":"icu"}}}');
-- Index search + JOIN with a regular filter (1000 films, 16k rentals):
SELECT f.film_id, f.title, c.name AS category, paradedb.score(f.film_id) AS score
FROM film f
JOIN film_category fc USING (film_id)
JOIN category c USING (category_id)
WHERE f @@@ 'description:documentary'
AND f.release_year = 2006
ORDER BY score DESC
LIMIT 10;
run_querylimitation: the@@@operator does not survive sqlglot re-serialization and is explicitly rejected by the guard. In MCP queries use standard FTS@@(e.g.fulltext @@ to_tsquery('english','amazing'));@@@/BM25 remain available when talking to the database directly (psql).
Installing as a package
pip install . # or: pip install -e ".[dev]" for development
pg-mcp-qauth # console script (equivalent to python -m pg_mcp_qauth.server)Notes
Verified on FastMCP 4.x (
RemoteAuthProviderwrappingfastmcp.server.auth.providers.jwt.JWTVerifier,fastmcp.server.dependencies.get_access_token,httptransport). Important: a bareJWTVerifierdoes not publish RFC 9728 metadata routes — only theRemoteAuthProviderwrapper does.Dynamic Client Registration (RFC 7591) is deprecated in the latest MCP spec and is not the primary onboarding mechanism here.
Security
Do not report vulnerabilities via public issues — see SECURITY.md.
Contributing
See CONTRIBUTING.md — setup, running tests (unit / integration / Keycloak), PR requirements.
License
MIT © 2026 martystev
This server cannot be deployed
Maintenance
Related MCP Connectors
Guard AI agents' PostgreSQL/MySQL access via MCP: SQL audit, auth, masking, write approval
13Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
Governed MCP gateway: one endpoint for your tools, with credential custody and audit log.
Self-hosted federated MCP gateway: one OAuth 2.1 MCP server in front of N apps, user-level scopes.
Related MCP Servers
- AlicenseAqualityCmaintenanceEnables secure querying of PostgreSQL databases through MCP-compatible clients. Supports read-only SQL execution, table exploration, and connection management with built-in security validation.330 npm9MIT
- AlicenseAqualityCmaintenanceReadonly PostgreSQL MCP server with SQL guardrails for analytical queries and schema introspection.217 npmMIT
- AlicenseNot gradedqualityCmaintenanceProvides a read-only PostgreSQL MCP server with schema introspection. Enforces least-privilege database roles to prevent any writes, even from malicious SQL.MIT
- 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