Sign inSign up

n8500x/mcp-postgresql

By n8500x

Updated about 2 months ago

Image
0

464

n8500x/mcp-postgresql repository overview

mcp-postgresql

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.

  • 🔒 Read-only, enforced by PostgreSQL — every statement runs inside 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.
  • 🎯 Version-aware — PostgreSQL 13–17. Capabilities are probed per target, so a tool needing pg_stat_io (16+) or pg_stat_statements explains what to install rather than failing with a missing-relation error.
  • ⏱️ Bounded — statement timeout, lock timeout, row caps, result budget. A diagnostic tool must never become the incident.
  • 📦 Self-contained — sources, tests, dev stack, k8s manifests and docs are all baked in, so 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.
  • 🔌 No libpq coupling — the server speaks the PostgreSQL wire protocol through node-postgres and never shells out. (psql is included for humans debugging inside the container, not used by the server.)

Run it

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

Kubernetes — and getting a shell in the pod

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

The database role

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;

How an investigation goes

"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).


Tools

⚠️ marks tools that can change state; all are disabled unless explicitly enabled.

Composite RCA — start here
ToolWhat it answers
pg_rca_snapshotRuns the entire first-pass investigation concurrently and returns one report: server vi…
pg_rca_health_checkEvaluates the instance against a checklist of known failure modes and returns findings …
pg_rca_investigate_queryGiven a queryid, gathers the full statistics, the tables it touches (with their sizes…
pg_rca_investigate_tableComplete picture of a single table: size breakdown, row and dead-tuple counts, scan mix…
Inventory & orientation
ToolWhat it answers
pg_list_targetsList the PostgreSQL instances this server can reach, with the default one marked.
pg_server_infoVersion, uptime, role (primary or standby), current database/user, connection count vs …
pg_list_databasesEvery database on the instance with its size, connection count, cache hit ratio, deadlo…
pg_list_schemasSchemas in the current database with owner, table/view/index counts and total size.
pg_list_tablesTables (and partitioned/materialised views) with total size, live-row estimate, sequent…
pg_describe_tableFull definition of one table — columns with types/nullability/defaults, indexes and the…
pg_list_indexesAll indexes with definition, size, scan count, tuples read/fetched and validity.
pg_list_extensionsInstalled extensions with their version, plus whether the diagnostic extensions this se…
pg_database_statspg_stat_database for the current database: commits/rollbacks, blocks hit vs read, tuple…
pg_sequence_exhaustionSequences ranked by how much of their range is consumed, including the column type they…
Live activity
ToolWhat it answers
pg_active_sessionsLive sessions from pg_stat_activity: pid, user, application, client address, state, wai…
pg_connection_summaryAggregated connection counts versus max_connections, broken down by state/user/database…
pg_wait_eventsCounts of what sessions are currently waiting on, grouped by wait_event_type/wait_event…
pg_idle_in_transactionSessions in idle in transaction (or its aborted variant) ordered by how long they hav…
pg_long_running_transactionsEvery session with an open transaction older than the threshold, plus how far behind th…
pg_progress_activityLive progress of VACUUM, ANALYZE, CREATE INDEX, CLUSTER, base backup and COPY — phase, …
Locks & blocking
ToolWhat it answers
pg_blocking_treeReconstructs the blocking hierarchy from pg_blocking_pids as an indented tree: each roo…
pg_lock_detailsEvery lock request with its type, mode, target relation, whether it was granted, and th…
pg_lock_contention_summaryWhich relations are the contention hot spots: waiters per relation and lock mode, longe…
pg_deadlock_summaryDeadlock counters per database since the last stats reset, alongside deadlock_timeout
Query statistics
ToolWhat it answers
pg_top_queriesRanked statement statistics from pg_stat_statements: calls, total/mean/max time, rows, …
pg_query_detailEvery recorded metric for a single queryid — timing distribution, buffer hits/reads/d…
pg_temp_file_usageTemp-file volume per database and the statements responsible.
pg_statements_overviewOne-row summary of everything pg_stat_statements has recorded: distinct statements trac…
Execution plans
ToolWhat it answers
pg_explain_queryRun EXPLAIN (optionally ANALYZE) on a statement and return the plan as a readable node …
Index health
ToolWhat it answers
pg_unused_indexesNon-constraint indexes with a scan count at or below a threshold, with their size and t…
pg_duplicate_indexesIndexes that are exact duplicates of another, or whose column list is a leading prefix …
pg_invalid_indexesIndexes marked invalid or not-ready — almost always the debris of a failed or cancelled…
pg_missing_index_candidatesTables ranked by sequential-scan pressure: scan count, rows read per scan, table size a…
pg_fk_without_indexForeign key constraints whose referencing columns are not covered by any index.
pg_index_usagePer-index scan counts with tuples read and fetched, plus the read/fetch ratio.
Storage & bloat
ToolWhat it answers
pg_table_sizesRelations ranked by total size with the heap, index and TOAST components broken out, pl…
pg_table_bloatStatistical bloat estimate per table: actual size versus the size the rows should occup…
pg_table_bloat_exactPrecise tuple-level statistics for a single table from pgstattuple: live/dead tuple cou…
pg_index_bloatB-tree indexes ranked by estimated wasted space, from average key width and page fill.
Vacuum & wraparound
ToolWhat it answers
pg_vacuum_statusPer-table dead tuples, dead percentage, last manual/auto vacuum and analyze times, how …
pg_autovacuum_activityCurrently running (auto)vacuum workers with their phase, block progress, how long they …
pg_transaction_wraparoundHow close the cluster is to transaction-ID wraparound, per database and per table, as b…
Replication & WAL
ToolWhat it answers
pg_replication_statusEvery connected standby with its state, sent/write/flush/replay LSNs, byte lag at each …
pg_replication_slotsEvery replication slot with active/inactive state, the WAL retained behind it in bytes,…
pg_wal_activityCurrent WAL position and segment count on disk, archiver success/failure counts and the…
pg_standby_statusStandby-side view: last received and replayed LSN, replay lag in seconds behind the pri…
pg_logical_replicationPublications defined here and subscriptions consuming from elsewhere, with their worker…
I/O, cache & checkpoints
ToolWhat it answers
pg_cache_hit_ratioCache hit ratio for the database as a whole and for the busiest tables and indexes, alo…
pg_checkpoint_statsCheckpoint counts split by trigger (timed vs requested), time spent writing and syncing…
pg_io_statspg_stat_io: reads, writes, extends, hits, evictions, reuses and fsyncs broken down by b…
pg_buffer_cache_contentsRelations ranked by how many shared buffers they occupy, with the share of the cache an…
Configuration
ToolWhat it answers
pg_show_settingsSettings matching a name pattern or category, showing the current value, unit, default,…
pg_nondefault_settingsEvery setting whose value differs from the compiled-in default, with where it was set.
pg_config_reviewCompares the memory, checkpoint, autovacuum, planner and logging settings against the m…
Ad-hoc SQL
ToolWhat it answers
pg_run_queryExecute 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…
Administrative (gated)
ToolWhat 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.

Configuration

Connection
VariableDefaultNotes
PG_URL(libpq env)Primary DSN; unset falls back to PGHOST/PGUSER/…
PG_TARGETSExtra named targets: {"primary":"…","replica":"…"} or primary=…;replica=…
PG_DEFAULT_TARGETdefaultTarget used when target is omitted
PG_APPLICATION_NAMEmcp-postgresqlAppears in pg_stat_activity on the inspected server
PG_SSL_MODEpreferdisable/prefer/require/verify-ca/verify-full/no-verify
PG_SSL_ROOT_CERTPath to a CA bundle
PG_POOL_MAX4Small on purpose — never exhaust max_connections
Limits
VariableDefaultNotes
PG_STATEMENT_TIMEOUT_MS15000Server-side cancel — the real protection
PG_LOCK_TIMEOUT_MS3000Never queue behind someone else's lock
PG_MAX_ROWS500Row cap, reported when applied
MCP_TOOL_TIMEOUT_MS30000Per-call deadline
MCP_MAX_CONCURRENCY4Concurrent tool calls
MCP_MAX_RESULT_CHARS24000≈6k tokens; clipping is announced to the model
Safety switches — all default to off
VariableDefaultEnables
PG_ALLOW_WRITEfalsepg_run_write_query (DML/DDL)
PG_ALLOW_ADMINfalsepg_cancel_query, pg_terminate_session, pg_reset_statistics
PG_ALLOW_EXPLAIN_ANALYZEfalseEXPLAIN ANALYZE, which executes the statement
PG_INCLUDE_QUERY_TEXTtrueQuery text in output — set false where statements may embed PII

Diagnosis never needs any of these.

Transport, logging, output
VariableDefaultNotes
MCP_TRANSPORTstdioor http
MCP_HTTP_HOST / MCP_HTTP_PORT0.0.0.0 / 3000
MCP_HTTP_PATH / MCP_HTTP_HEALTH_PATH/mcp / /healthz
MCP_HTTP_BEARER_TOKENRequired on the JSON-RPC endpoint when set
MCP_HTTP_ALLOWED_ORIGINS*DNS-rebinding guard for browser clients
MCP_LOG_LEVELinfoAlways stderr — stdout is the protocol
MCP_LOG_FORMATjsonor pretty
MCP_OUTPUT_FORMATmarkdownFar cheaper in tokens than json for wide grids
Trimming the tool surface
-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

What is inside

A TypeScript monorepo, deliberately layered so a second backend is cheap:

PackageRole
@n8500x/mcp-coreBackend-agnostic MCP framework — typed config, redacting structured logging, tool registry, middleware (timeout/concurrency/errors/rendering), stdio + HTTP transports, result budgeting
@n8500x/mcp-postgresqlDomain knowledge only — connection targets, the single SQL funnel, capability probing, 59 tool declarations
@n8500x/mcp-harnessA 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.


Local development and testing

Clone the source, then:

npm install
npm run build
Bring up a database with real problems to find

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"
Drive the server by hand

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
The test commands
CommandWhat it proves
npm test120 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 -- doctorCalls every zero-argument read-only tool against a real server and reports which SQL actually works on that PostgreSQL version
npm run dev:smoke33-step scenario: finds each planted defect, and asserts the safety policy holds (writes refused, multi-statement refused, admin tools gated)
npm run lintESLint, type-aware across src and test
npm run buildTypeScript project references, all three packages
npm run docs:checkFails if a tool was added without regenerating the tool tables
npm run verifyAll of the above: lint + build + test + docs:check
npm run list-toolsPrint 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
Testing on-prem / air-gapped, from inside the image

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 .
Air-gapped install
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

Tag summary

Content type

Image

Digest

sha256:62508d253

Size

136.6 MB

Last updated

about 2 months ago

docker pull n8500x/mcp-postgresql