A PostgreSQL root-cause-analysis MCP server. 59 read-only diagnostic tools, 4 composite RCA reports and 3 investigation prompts — enough for an agent to take "the database is slow" from symptom to a specific, evidenced root cause.
BEGIN TRANSACTION READ ONLY, so a write is rejected by the server, not by a
regular expression. Writes, session control and EXPLAIN ANALYZE are separate,
default-off switches.pg_stat_io (16+) or pg_stat_statements explains what to
install rather than failing with a missing-relation error.npm run selftest works on an air-gapped host with no
registry and no checkout. ~459 MB, non-root. A lean build target
(~191 MB) drops the dev tooling for production deployments.psql is included for humans
debugging inside the container, not used by the server.)stdio (an MCP host spawns it):
docker run --rm -i \
-e PG_URL="postgres://readonly_user:[email protected]:5432/appdb" \
n8500x/mcp-postgresql
HTTP (one shared service for many agents; stateless, so replicas need no session affinity):
docker run -d -p 3000:3000 \
-e MCP_TRANSPORT=http \
-e PG_URL="postgres://readonly_user:[email protected]:5432/appdb" \
n8500x/mcp-postgresql
# endpoint http://localhost:3000/mcp
# readiness http://localhost:3000/healthz (actually probes the database)
MCP host configuration:
{
"mcpServers": {
"postgres": {
"command": "docker",
"args": ["run", "--rm", "-i", "-e", "PG_URL", "n8500x/mcp-postgresql"],
"env": { "PG_URL": "postgres://readonly_user:[email protected]:5432/appdb" }
}
}
}
List the catalogue without connecting to anything:
docker run --rm n8500x/mcp-postgresql --list-tools
Use MCP_TRANSPORT=http for any long-running deployment. The stdio default
expects an MCP host to own the process and speak JSON-RPC over the pipe. A
Kubernetes container has nothing on stdin, so the server hits EOF, exits 0,
and the pod CrashLoopBackOffs with a successful exit code — leaving nothing to
exec into. The server logs exactly that if it happens, rather than exiting
silently.
Manifests are in k8s/
(Deployment + Service, non-root UID 10001, read-only rootfs, readiness probe
that actually queries the database):
kubectl create namespace mcp
kubectl -n mcp create secret generic mcp-postgresql \
--from-literal=PG_URL='postgres://mcp_readonly:[email protected]:5432/appdb'
kubectl -n mcp apply -f k8s/
kubectl -n mcp exec -it deploy/mcp-postgresql -- bash
The image ships bash — alpine has only busybox sh, so
--entrypoint=/bin/bash and kubectl exec … -- bash would otherwise fail with
a runc "no such file or directory" that reads like a broken image. Also
included: curl, both CLIs on PATH, and the full documentation baked in at
/app/docs.
Once inside the pod:
mcp-harness --http http://127.0.0.1:3000/mcp doctor # test the live server
mcp-harness call pg_server_info # or spawn a private one
curl -s localhost:3000/healthz
cat /app/docs/README.md
Locally, the same:
docker run --rm -it --entrypoint=/bin/bash n8500x/mcp-postgresql
Full diagnostics need pg_monitor — without it PostgreSQL hides other sessions'
query text and much of the statistics.
CREATE ROLE mcp_readonly LOGIN PASSWORD '…';
GRANT pg_monitor TO mcp_readonly;
GRANT CONNECT ON DATABASE appdb TO mcp_readonly;
GRANT USAGE ON SCHEMA public TO mcp_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly;
-- pg_monitor does NOT cover sequences; without this, pg_sequence_exhaustion
-- cannot read last_value and would report "not visible" rather than a false all-clear.
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO mcp_readonly;
Also install pg_stat_statements — it is the single most valuable input to any
query-performance investigation:
-- postgresql.conf: shared_preload_libraries = 'pg_stat_statements' (needs a restart)
CREATE EXTENSION pg_stat_statements;
"checkout API times out since 14:20"
│
├─▶ pg_rca_snapshot one call, twelve probes, whole board
│ └─ 14 sessions blocked, root blocker idle in transaction
│
├─▶ pg_blocking_tree find the ROOT blocker (depth 0), not a victim
│ └─ pid 4412, idle in transaction 22 min, holding 3 locks
│
├─▶ pg_idle_in_transaction confirm: which app, which last statement
│ └─ application_name=checkout-worker
│
└─▶ root cause: the worker opens a transaction around an external HTTP call.
Mitigation: terminate pid 4412. Fix: move the call outside the
transaction; set idle_in_transaction_session_timeout as a backstop.
Three prompts — pg_rca_triage, pg_slow_query_analysis, pg_health_review —
encode that method: the order to work in, what each signal means, and the failure
mode to avoid (latching onto the first plausible thread).
⚠️ marks tools that can change state; all are disabled unless explicitly enabled.
| Tool | What it answers |
|---|---|
pg_rca_snapshot | Runs the entire first-pass investigation concurrently and returns one report: server vi… |
pg_rca_health_check | Evaluates the instance against a checklist of known failure modes and returns findings … |
pg_rca_investigate_query | Given a queryid, gathers the full statistics, the tables it touches (with their sizes… |
pg_rca_investigate_table | Complete picture of a single table: size breakdown, row and dead-tuple counts, scan mix… |
| Tool | What it answers |
|---|---|
pg_list_targets | List the PostgreSQL instances this server can reach, with the default one marked. |
pg_server_info | Version, uptime, role (primary or standby), current database/user, connection count vs … |
pg_list_databases | Every database on the instance with its size, connection count, cache hit ratio, deadlo… |
pg_list_schemas | Schemas in the current database with owner, table/view/index counts and total size. |
pg_list_tables | Tables (and partitioned/materialised views) with total size, live-row estimate, sequent… |
pg_describe_table | Full definition of one table — columns with types/nullability/defaults, indexes and the… |
pg_list_indexes | All indexes with definition, size, scan count, tuples read/fetched and validity. |
pg_list_extensions | Installed extensions with their version, plus whether the diagnostic extensions this se… |
pg_database_stats | pg_stat_database for the current database: commits/rollbacks, blocks hit vs read, tuple… |
pg_sequence_exhaustion | Sequences ranked by how much of their range is consumed, including the column type they… |
| Tool | What it answers |
|---|---|
pg_active_sessions | Live sessions from pg_stat_activity: pid, user, application, client address, state, wai… |
pg_connection_summary | Aggregated connection counts versus max_connections, broken down by state/user/database… |
pg_wait_events | Counts of what sessions are currently waiting on, grouped by wait_event_type/wait_event… |
pg_idle_in_transaction | Sessions in idle in transaction (or its aborted variant) ordered by how long they hav… |
pg_long_running_transactions | Every session with an open transaction older than the threshold, plus how far behind th… |
pg_progress_activity | Live progress of VACUUM, ANALYZE, CREATE INDEX, CLUSTER, base backup and COPY — phase, … |
| Tool | What it answers |
|---|---|
pg_blocking_tree | Reconstructs the blocking hierarchy from pg_blocking_pids as an indented tree: each roo… |
pg_lock_details | Every lock request with its type, mode, target relation, whether it was granted, and th… |
pg_lock_contention_summary | Which relations are the contention hot spots: waiters per relation and lock mode, longe… |
pg_deadlock_summary | Deadlock counters per database since the last stats reset, alongside deadlock_timeout… |
| Tool | What it answers |
|---|---|
pg_top_queries | Ranked statement statistics from pg_stat_statements: calls, total/mean/max time, rows, … |
pg_query_detail | Every recorded metric for a single queryid — timing distribution, buffer hits/reads/d… |
pg_temp_file_usage | Temp-file volume per database and the statements responsible. |
pg_statements_overview | One-row summary of everything pg_stat_statements has recorded: distinct statements trac… |
| Tool | What it answers |
|---|---|
pg_explain_query | Run EXPLAIN (optionally ANALYZE) on a statement and return the plan as a readable node … |
| Tool | What it answers |
|---|---|
pg_unused_indexes | Non-constraint indexes with a scan count at or below a threshold, with their size and t… |
pg_duplicate_indexes | Indexes that are exact duplicates of another, or whose column list is a leading prefix … |
pg_invalid_indexes | Indexes marked invalid or not-ready — almost always the debris of a failed or cancelled… |
pg_missing_index_candidates | Tables ranked by sequential-scan pressure: scan count, rows read per scan, table size a… |
pg_fk_without_index | Foreign key constraints whose referencing columns are not covered by any index. |
pg_index_usage | Per-index scan counts with tuples read and fetched, plus the read/fetch ratio. |
| Tool | What it answers |
|---|---|
pg_table_sizes | Relations ranked by total size with the heap, index and TOAST components broken out, pl… |
pg_table_bloat | Statistical bloat estimate per table: actual size versus the size the rows should occup… |
pg_table_bloat_exact | Precise tuple-level statistics for a single table from pgstattuple: live/dead tuple cou… |
pg_index_bloat | B-tree indexes ranked by estimated wasted space, from average key width and page fill. |
| Tool | What it answers |
|---|---|
pg_vacuum_status | Per-table dead tuples, dead percentage, last manual/auto vacuum and analyze times, how … |
pg_autovacuum_activity | Currently running (auto)vacuum workers with their phase, block progress, how long they … |
pg_transaction_wraparound | How close the cluster is to transaction-ID wraparound, per database and per table, as b… |
| Tool | What it answers |
|---|---|
pg_replication_status | Every connected standby with its state, sent/write/flush/replay LSNs, byte lag at each … |
pg_replication_slots | Every replication slot with active/inactive state, the WAL retained behind it in bytes,… |
pg_wal_activity | Current WAL position and segment count on disk, archiver success/failure counts and the… |
pg_standby_status | Standby-side view: last received and replayed LSN, replay lag in seconds behind the pri… |
pg_logical_replication | Publications defined here and subscriptions consuming from elsewhere, with their worker… |
| Tool | What it answers |
|---|---|
pg_cache_hit_ratio | Cache hit ratio for the database as a whole and for the busiest tables and indexes, alo… |
pg_checkpoint_stats | Checkpoint counts split by trigger (timed vs requested), time spent writing and syncing… |
pg_io_stats | pg_stat_io: reads, writes, extends, hits, evictions, reuses and fsyncs broken down by b… |
pg_buffer_cache_contents | Relations ranked by how many shared buffers they occupy, with the share of the cache an… |
| Tool | What it answers |
|---|---|
pg_show_settings | Settings matching a name pattern or category, showing the current value, unit, default,… |
pg_nondefault_settings | Every setting whose value differs from the compiled-in default, with where it was set. |
pg_config_review | Compares the memory, checkpoint, autovacuum, planner and logging settings against the m… |
| Tool | What it answers |
|---|---|
pg_run_query | Execute one read-only statement and return the rows. |
pg_run_write_query ⚠️ | Execute one data- or schema-modifying statement in a normal (writable) transaction, com… |
| Tool | What it answers |
|---|---|
pg_cancel_query ⚠️ | Send a cancel signal to one backend, stopping its CURRENT statement while leaving the c… |
pg_terminate_session ⚠️ | Forcibly close one backend. |
pg_reset_statistics ⚠️ | Clear a cumulative statistics view so the next measurement window starts from zero. |
| Variable | Default | Notes |
|---|---|---|
PG_URL | (libpq env) | Primary DSN; unset falls back to PGHOST/PGUSER/… |
PG_TARGETS | — | Extra named targets: {"primary":"…","replica":"…"} or primary=…;replica=… |
PG_DEFAULT_TARGET | default | Target used when target is omitted |
PG_APPLICATION_NAME | mcp-postgresql | Appears in pg_stat_activity on the inspected server |
PG_SSL_MODE | prefer | disable/prefer/require/verify-ca/verify-full/no-verify |
PG_SSL_ROOT_CERT | — | Path to a CA bundle |
PG_POOL_MAX | 4 | Small on purpose — never exhaust max_connections |
| Variable | Default | Notes |
|---|---|---|
PG_STATEMENT_TIMEOUT_MS | 15000 | Server-side cancel — the real protection |
PG_LOCK_TIMEOUT_MS | 3000 | Never queue behind someone else's lock |
PG_MAX_ROWS | 500 | Row cap, reported when applied |
MCP_TOOL_TIMEOUT_MS | 30000 | Per-call deadline |
MCP_MAX_CONCURRENCY | 4 | Concurrent tool calls |
MCP_MAX_RESULT_CHARS | 24000 | ≈6k tokens; clipping is announced to the model |
| Variable | Default | Enables |
|---|---|---|
PG_ALLOW_WRITE | false | pg_run_write_query (DML/DDL) |
PG_ALLOW_ADMIN | false | pg_cancel_query, pg_terminate_session, pg_reset_statistics |
PG_ALLOW_EXPLAIN_ANALYZE | false | EXPLAIN ANALYZE, which executes the statement |
PG_INCLUDE_QUERY_TEXT | true | Query text in output — set false where statements may embed PII |
Diagnosis never needs any of these.
| Variable | Default | Notes |
|---|---|---|
MCP_TRANSPORT | stdio | or http |
MCP_HTTP_HOST / MCP_HTTP_PORT | 0.0.0.0 / 3000 | |
MCP_HTTP_PATH / MCP_HTTP_HEALTH_PATH | /mcp / /healthz | |
MCP_HTTP_BEARER_TOKEN | — | Required on the JSON-RPC endpoint when set |
MCP_HTTP_ALLOWED_ORIGINS | * | DNS-rebinding guard for browser clients |
MCP_LOG_LEVEL | info | Always stderr — stdout is the protocol |
MCP_LOG_FORMAT | json | or pretty |
MCP_OUTPUT_FORMAT | markdown | Far cheaper in tokens than json for wide grids |
-e MCP_TOOLS_CATEGORIES=rca,activity,locks # only these categories
-e MCP_TOOLS_ENABLE='pg_rca_*,pg_list_*' # glob allow-list
-e MCP_TOOLS_DISABLE='pg_run_*' # glob deny-list
-e MCP_TOOLS_READONLY_ONLY=true # drop anything that can change state
A TypeScript monorepo, deliberately layered so a second backend is cheap:
| Package | Role |
|---|---|
@n8500x/mcp-core | Backend-agnostic MCP framework — typed config, redacting structured logging, tool registry, middleware (timeout/concurrency/errors/rendering), stdio + HTTP transports, result budgeting |
@n8500x/mcp-postgresql | Domain knowledge only — connection targets, the single SQL funnel, capability probing, 59 tool declarations |
@n8500x/mcp-harness | A local MCP client for driving any MCP server: list/describe/call, a REPL, a scenario runner, and doctor (calls every read-only tool and reports what actually works on your server version) |
Tags are YYYYMMDD-HHMMSS plus latest.
Clone the source, then:
npm install
npm run build
dev/ starts PostgreSQL 17 with pg_stat_statements, seeded deliberately
badly so every diagnostic tool has a known-positive case: 78 MB of bloat, an
unindexed foreign key, duplicate/unused/invalid indexes, an int4 sequence at
92%, and stale statistics.
npm run dev:up # start + seed (~30s)
npm run dev:workload # generate query statistics for pg_top_queries
npm run dev:down # stop and drop the volume
export PG_URL="postgres://mcp_readonly:[email protected]:55432/rcademo"
mcp-harness is a plain MCP client. Testing an MCP server through a chat client
is ambiguous — a failure could be the server, the model's tool choice, or the
host. Here every call is explicit.
npm run harness -- tools # list all 59 (filter: tools rca)
npm run harness -- describe pg_top_queries # schema + annotations
npm run harness -- call pg_rca_snapshot # the composite triage report
npm run harness -- call pg_top_queries limit=5 order_by=mean_time
npm run harness -- prompts
npm run harness -- prompt pg_rca_triage symptom="checkout is slow"
npm run harness -- repl # interactive session
npm run harness -- info # server identity + instructions
# against an HTTP deployment instead of spawning one:
npm run harness -- --http http://localhost:3000/mcp doctor
| Command | What it proves |
|---|---|
npm test | 120 unit tests — SQL guards (every bypass attempt considered), error classification, config parsing, result rendering, and mechanical catalogue invariants (naming, description length, annotation truthfulness) |
npm run harness -- doctor | Calls every zero-argument read-only tool against a real server and reports which SQL actually works on that PostgreSQL version |
npm run dev:smoke | 33-step scenario: finds each planted defect, and asserts the safety policy holds (writes refused, multi-statement refused, admin tools gated) |
npm run lint | ESLint, type-aware across src and test |
npm run build | TypeScript project references, all three packages |
npm run docs:check | Fails if a tool was added without regenerating the tool tables |
npm run verify | All of the above: lint + build + test + docs:check |
npm run list-tools | Print the catalogue without connecting to anything |
doctor is the one that earns its keep. Unit tests cannot tell you that
pg_stat_checkpointer lost a column in PostgreSQL 17, or that an expression
came out double precision where round() needs numeric. It found six such
bugs on its first run against a real server — including a bloat estimator
reading relpages, which is zero on a never-vacuumed table, so the most
bloated table in the database reported "0 bytes".
To exercise the lock tools, open a real blocking transaction:
docker exec -it mcp-pg-dev psql -U postgres -d rcademo -f /dev/stdin < dev/make-blocking.sql
# then, from another terminal:
docker exec -it mcp-pg-dev psql -U postgres -d rcademo -c "UPDATE orders SET status='paid' WHERE id=1;"
npm run harness -- call pg_blocking_tree
The image is self-contained: sources, tests, dev/, k8s/, scripts/,
docs and the dev dependencies are all baked in, plus bash, psql, git,
jq, curl and the docker CLI. An air-gapped host has no npm registry and no
git checkout, so everything has to work from inside the artefact.
docker run --rm \
-e PG_URL="postgres://mcp_readonly:[email protected]:5432/appdb" \
--entrypoint npm n8500x/mcp-postgresql run selftest
Five stages, cheapest and most diagnostic first, degrading sensibly when there is no database to point at:
1. Unit tests (no database required) ✓ 120 passed
2. Tool catalogue (no database required) ✓ 59 tools
3. Connectivity ✓ connected — PostgreSQL 17.10
4. Doctor — every read-only tool ✓ 47 ok, 1 unavailable, 0 failed
5. Smoke scenario ✓ all 33 steps passed
Anything individually:
docker run --rm --entrypoint npm n8500x/mcp-postgresql test
docker run --rm --entrypoint npm n8500x/mcp-postgresql run lint
docker run --rm --entrypoint bash n8500x/mcp-postgresql -c 'mcp-harness doctor'
Podman is CLI-compatible throughout — substitute the binary name. The differences that bite:
podman run --rm -e PG_URL=... --entrypoint npm n8500x/mcp-postgresql run selftest
podman save n8500x/mcp-postgresql:latest -o mcp-postgresql.tar
podman-compose -f dev/docker-compose.yml up -d # or: podman compose
# the k8s manifests, with no cluster:
podman kube play k8s/secret.example.yaml k8s/deployment.yaml
podman exec -it mcp-postgresql-pod-mcp-postgresql bash
SELinux: on RHEL-family hosts a bind mount without a relabel is unreadable by
the container — initdb then runs none of the seed scripts and you get an empty
database with nothing in the log to explain it. The dev compose file already
carries :ro,z; do the same for any mount you add.
Rootless: the image runs as UID 10001 and writes nothing outside /tmp, so no
--userns tricks are needed, and both published ports are above 1024.
npm run dev:up does not work from inside the container. Its compose file
bind-mounts ./init, and those paths are resolved by the Docker daemon on the
host — so even with /var/run/docker.sock mounted it would mount empty
directories and seed nothing. Either point PG_URL at a PostgreSQL you already
have, or extract the dev stack and run it on the host:
docker run --rm --entrypoint tar n8500x/mcp-postgresql -cf - dev | tar -xf -
docker compose -f dev/docker-compose.yml up -d
A lean target exists for production deployments that will never be tested in
place — compiled output and production dependencies only:
docker build --target lean -t mcp-postgresql:lean .
docker save n8500x/mcp-postgresql:latest -o mcp-postgresql.tar # ~182 MB
docker load -i mcp-postgresql.tar # on the target
To extract just the app tree (~23 MB: compiled output plus production dependencies), on Linux — the tree contains npm workspace symlinks, which Windows cannot create without Developer Mode:
docker run --rm --entrypoint tar n8500x/mcp-postgresql:latest -cf - -C / app > app.tar
tar -xf app.tar -C /opt
PG_URL=... node /opt/app/packages/postgres/dist/main.js # any Node 20+
Source: https://github.com/fdf3d186-88d5/docker/tree/main/mcp-postgresql Licence: MIT
Content type
Image
Digest
sha256:62508d253…
Size
136.6 MB
Last updated
about 2 months ago
docker pull n8500x/mcp-postgresql