pg-admin-mcp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +608 -2
  2. package/dist/config/env.js +67 -0
  3. package/dist/config/env.js.map +1 -0
  4. package/dist/config/profiles.js +257 -0
  5. package/dist/config/profiles.js.map +1 -0
  6. package/dist/core/audit-context.js +21 -0
  7. package/dist/core/audit-context.js.map +1 -0
  8. package/dist/core/audit.js +91 -0
  9. package/dist/core/audit.js.map +1 -0
  10. package/dist/core/brands.js +24 -0
  11. package/dist/core/brands.js.map +1 -0
  12. package/dist/core/classifier.js +279 -0
  13. package/dist/core/classifier.js.map +1 -0
  14. package/dist/core/errors.js +197 -0
  15. package/dist/core/errors.js.map +1 -0
  16. package/dist/core/format.js +258 -0
  17. package/dist/core/format.js.map +1 -0
  18. package/dist/core/guards.js +513 -0
  19. package/dist/core/guards.js.map +1 -0
  20. package/dist/core/identifiers.js +431 -0
  21. package/dist/core/identifiers.js.map +1 -0
  22. package/dist/core/limits.js +79 -0
  23. package/dist/core/limits.js.map +1 -0
  24. package/dist/core/logger.js +34 -0
  25. package/dist/core/logger.js.map +1 -0
  26. package/dist/core/paginate.js +195 -0
  27. package/dist/core/paginate.js.map +1 -0
  28. package/dist/core/paths.js +392 -0
  29. package/dist/core/paths.js.map +1 -0
  30. package/dist/core/policy.js +98 -0
  31. package/dist/core/policy.js.map +1 -0
  32. package/dist/core/shutdown.js +10 -0
  33. package/dist/core/shutdown.js.map +1 -0
  34. package/dist/core/stdout-guard.js +25 -0
  35. package/dist/core/stdout-guard.js.map +1 -0
  36. package/dist/db/catalog.js +1006 -0
  37. package/dist/db/catalog.js.map +1 -0
  38. package/dist/db/pool.js +158 -0
  39. package/dist/db/pool.js.map +1 -0
  40. package/dist/db/session.js +258 -0
  41. package/dist/db/session.js.map +1 -0
  42. package/dist/db/types.js +2 -0
  43. package/dist/db/types.js.map +1 -0
  44. package/dist/index.js +91 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/resources/index.js +88 -0
  47. package/dist/resources/index.js.map +1 -0
  48. package/dist/runtime/binaries.js +356 -0
  49. package/dist/runtime/binaries.js.map +1 -0
  50. package/dist/runtime/env-file.js +62 -0
  51. package/dist/runtime/env-file.js.map +1 -0
  52. package/dist/runtime/environment.js +34 -0
  53. package/dist/runtime/environment.js.map +1 -0
  54. package/dist/server.js +106 -0
  55. package/dist/server.js.map +1 -0
  56. package/dist/snapshot/capture.js +189 -0
  57. package/dist/snapshot/capture.js.map +1 -0
  58. package/dist/snapshot/diff.js +254 -0
  59. package/dist/snapshot/diff.js.map +1 -0
  60. package/dist/snapshot/migrate.js +1596 -0
  61. package/dist/snapshot/migrate.js.map +1 -0
  62. package/dist/snapshot/model.js +80 -0
  63. package/dist/snapshot/model.js.map +1 -0
  64. package/dist/tools/_archive_meta.js +138 -0
  65. package/dist/tools/_archive_meta.js.map +1 -0
  66. package/dist/tools/_batch_shared.js +73 -0
  67. package/dist/tools/_batch_shared.js.map +1 -0
  68. package/dist/tools/_export_format.js +107 -0
  69. package/dist/tools/_export_format.js.map +1 -0
  70. package/dist/tools/_object_kinds.js +97 -0
  71. package/dist/tools/_object_kinds.js.map +1 -0
  72. package/dist/tools/_relation_detail.js +55 -0
  73. package/dist/tools/_relation_detail.js.map +1 -0
  74. package/dist/tools/_shared.js +17 -0
  75. package/dist/tools/_shared.js.map +1 -0
  76. package/dist/tools/_snapshot_io.js +238 -0
  77. package/dist/tools/_snapshot_io.js.map +1 -0
  78. package/dist/tools/admin/_shared.js +18 -0
  79. package/dist/tools/admin/_shared.js.map +1 -0
  80. package/dist/tools/admin/extensions.js +80 -0
  81. package/dist/tools/admin/extensions.js.map +1 -0
  82. package/dist/tools/admin/grants.js +207 -0
  83. package/dist/tools/admin/grants.js.map +1 -0
  84. package/dist/tools/admin/index.js +138 -0
  85. package/dist/tools/admin/index.js.map +1 -0
  86. package/dist/tools/admin/maintenance.js +106 -0
  87. package/dist/tools/admin/maintenance.js.map +1 -0
  88. package/dist/tools/admin/roles.js +144 -0
  89. package/dist/tools/admin/roles.js.map +1 -0
  90. package/dist/tools/admin/sessions.js +104 -0
  91. package/dist/tools/admin/sessions.js.map +1 -0
  92. package/dist/tools/admin/stats.js +63 -0
  93. package/dist/tools/admin/stats.js.map +1 -0
  94. package/dist/tools/backup.js +288 -0
  95. package/dist/tools/backup.js.map +1 -0
  96. package/dist/tools/connection_status.js +138 -0
  97. package/dist/tools/connection_status.js.map +1 -0
  98. package/dist/tools/copy.js +355 -0
  99. package/dist/tools/copy.js.map +1 -0
  100. package/dist/tools/delete_batch.js +73 -0
  101. package/dist/tools/delete_batch.js.map +1 -0
  102. package/dist/tools/describe_object.js +87 -0
  103. package/dist/tools/describe_object.js.map +1 -0
  104. package/dist/tools/execute_sql.js +395 -0
  105. package/dist/tools/execute_sql.js.map +1 -0
  106. package/dist/tools/explain.js +134 -0
  107. package/dist/tools/explain.js.map +1 -0
  108. package/dist/tools/export.js +215 -0
  109. package/dist/tools/export.js.map +1 -0
  110. package/dist/tools/get_schema.js +148 -0
  111. package/dist/tools/get_schema.js.map +1 -0
  112. package/dist/tools/index.js +40 -0
  113. package/dist/tools/index.js.map +1 -0
  114. package/dist/tools/insert_batch.js +212 -0
  115. package/dist/tools/insert_batch.js.map +1 -0
  116. package/dist/tools/list_objects.js +160 -0
  117. package/dist/tools/list_objects.js.map +1 -0
  118. package/dist/tools/prepare_migration.js +112 -0
  119. package/dist/tools/prepare_migration.js.map +1 -0
  120. package/dist/tools/restore.js +301 -0
  121. package/dist/tools/restore.js.map +1 -0
  122. package/dist/tools/schema_diff.js +79 -0
  123. package/dist/tools/schema_diff.js.map +1 -0
  124. package/dist/tools/schema_snapshot.js +57 -0
  125. package/dist/tools/schema_snapshot.js.map +1 -0
  126. package/dist/tools/select.js +162 -0
  127. package/dist/tools/select.js.map +1 -0
  128. package/dist/tools/update_batch.js +154 -0
  129. package/dist/tools/update_batch.js.map +1 -0
  130. package/package.json +47 -5
package/README.md CHANGED
@@ -1,3 +1,609 @@
1
- # Temporary Holding Version
1
+ # pg-admin-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [MCP](https://modelcontextprotocol.io) server, stdio transport, that gives an AI agent full
4
+ PostgreSQL access: connect, browse the catalog, read and paginate rows, run arbitrary SQL, bulk
5
+ insert/update/delete, DDL, snapshot and diff schemas, plan migrations, administer roles/grants/extensions/
6
+ sessions/maintenance, and back up or restore with `pg_dump`/`pg_restore`. 18 tools, 3 read-only resources.
7
+
8
+ Every connection profile is **read-only by default**. Writes, DDL, admin operations, `EXPLAIN ANALYZE`,
9
+ backup and restore are each a separate opt-in flag, set per profile via environment variables — nothing is
10
+ unlocked implicitly. Every destructive operation (bulk update/delete, guarded raw SQL, restore, role drop,
11
+ `pg_terminate_backend`, `REINDEX`, …) goes through a dry-run/confirm protocol: the first call previews the
12
+ change and returns a token, the second call must hand that exact token back to commit it.
13
+
14
+ **What it is not.** This server never elevates privileges — it only restricts. It runs as whatever
15
+ PostgreSQL role the connection string authenticates as; capability flags gate which of *that role's own*
16
+ privileges the server is willing to exercise, they do not grant new ones. A read-only transaction is not a
17
+ sandbox either: it won't stop `COPY … TO PROGRAM`, `lo_export()`, or advisory locks, so the documented
18
+ production posture is a role that is itself read-only at the database level
19
+ (`ALTER ROLE … SET default_transaction_read_only = on`, no `pg_write_server_files`).
20
+
21
+ Two ways to run it: **`npx -y pg-admin-mcp`** (primary — runs on your machine, needs Node.js >= 20.12)
22
+ or the **Docker image** (alternative — carries its own pinned PostgreSQL client tools; see
23
+ [Docker packaging](docs/design.md#docker-packaging) in the design doc).
24
+
25
+ ## Quick start (npx)
26
+
27
+ 1. **Copy the per-project template** from [`examples/per-project/`](examples/per-project/) into your
28
+ project root: `.mcp.json` (committed — profiles, environment classes, capability flags) and
29
+ `.env.example` (copy to `.env`, gitignored — the `PG_<ALIAS>_URL` lines with passwords).
30
+ ```bash
31
+ cp .env.example .env # then fill in the URLs
32
+ mkdir -p pg-workspace # exports, backups, snapshots land here
33
+ ```
34
+ `.mcp.json` launches `npx -y pg-admin-mcp` with `PG_MCP_ENV_FILE: ".env"`; the server reads that
35
+ file itself, and any key also set in `.mcp.json` wins over `.env`. On native Windows use
36
+ `"command": "cmd", "args": ["/c", "npx", "-y", "pg-admin-mcp"]`.
37
+ 2. **Start `claude` from the project root** — `.env` and `pg-workspace` are relative paths resolved
38
+ against the directory the client was launched from.
39
+ 3. **Verify** by calling `postgres_connection_status`. It reports `runtime: "host"`, reachability, the
40
+ server version, your role's privileges, and the client tool versions.
41
+
42
+ Host-mode notes:
43
+
44
+ - **The database host is plain `localhost`** for a database on your machine — the server is not in a
45
+ container, so no `host.docker.internal`.
46
+ - **Only `postgres_backup`/`postgres_restore` need the PostgreSQL client tools** (`pg_dump`,
47
+ `pg_restore`); the other 16 tools need nothing beyond Node. Install a client matching your server's
48
+ major — Windows `winget install PostgreSQL.PostgreSQL.17`, Debian/Ubuntu
49
+ `sudo apt install postgresql-client`, macOS `brew install libpq` — and, if it is not on `PATH`, set
50
+ `PG_MCP_PG_BIN_DIR` in `.env` (Windows: `C:\Program Files\PostgreSQL\17\bin`).
51
+ - `bunx pg-admin-mcp` should work too — untested.
52
+
53
+ ## Quick start (Docker, alternative)
54
+
55
+ 1. **Pull the image** (client binaries are pinned to a PostgreSQL major; use the tag matching your
56
+ server — `linux/amd64` only):
57
+ ```bash
58
+ docker pull mrrainx/pg-admin-mcp:pg17
59
+ ```
60
+ `manifest unknown` means no release has been cut yet — see [Status](#status) for how to tell, and
61
+ [Building the image locally](#building-the-image-locally) for the interim path.
62
+ 2. **Create your env file:**
63
+ ```bash
64
+ cp .env.example .env
65
+ ```
66
+ Edit `.env`: set `PG_MCP_PROFILES`, and for each alias a `PG_<ALIAS>_URL` plus whichever
67
+ `PG_<ALIAS>_ALLOW_*` flags you want on. See [Configuration](#configuration) and [Networking](#networking)
68
+ for the connection host — `localhost` will not reach your host machine from inside the container.
69
+ 3. **Create the workspace** the container writes files into (exports, backups, snapshots):
70
+ ```bash
71
+ mkdir -p pg-workspace
72
+ ```
73
+ 4. **Register it in your MCP client** — see [Connecting a client](#connecting-a-client) for the exact form
74
+ for Claude Code, Claude Desktop, Codex CLI, Cursor and VS Code.
75
+ 5. **Verify** by calling `postgres_connection_status` from the client. It reports `runtime: "container"`,
76
+ reachability, the server version, your role's privileges, and the `pg_dump`/`pg_restore`/`psql` client
77
+ versions — everything needed to confirm the server actually reached your database.
78
+
79
+ ## Using it from several projects
80
+
81
+ Run **one server process per project**, not one shared server for every project on the machine. The
82
+ profile/capability model (`PG_MCP_PROFILES`, `PG_<ALIAS>_URL`, `PG_<ALIAS>_ALLOW_*`) is scoped to the
83
+ process, not to the caller — it assumes the process belongs to a single client. A server shared
84
+ across projects would let one project's agent name another project's profile alias and reach its
85
+ database, `prod` included. One process per project, launched from that project's own config, keeps
86
+ each project's agent limited to the profiles that project itself defines.
87
+
88
+ **With npx (primary)** this is what [Quick start (npx)](#quick-start-npx) already sets up: the client
89
+ spawns a fresh `npx -y pg-admin-mcp` per session from the project's own `.mcp.json` + `.env`. The
90
+ template is [`examples/per-project/`](examples/per-project/).
91
+
92
+ **With Docker (alternative)** it is one container per project, launched by a small Compose file.
93
+ Pull the image once — the same image is reused across every project (see [Quick start (Docker, alternative)](#quick-start-docker-alternative)
94
+ if this returns `manifest unknown`):
95
+
96
+ ```bash
97
+ docker pull mrrainx/pg-admin-mcp:pg17
98
+ ```
99
+
100
+ Each project then carries three small committed files plus a gitignored `.env`:
101
+
102
+ ```
103
+ myproject/
104
+ .mcp.json # committed — identical in every project, never edited
105
+ pg_mcp.yml # committed — this project's profiles and environments, no passwords
106
+ .env # gitignored — passwords only
107
+ pg-workspace/ # gitignored — where exports, dumps and snapshots land
108
+ ```
109
+
110
+ `pg_mcp.yml` is a Docker Compose file. The server itself has no config-file format — it only reads
111
+ environment variables — so Compose here is nothing more than a launcher that sets those variables and
112
+ interpolates the passwords in from `.env`:
113
+
114
+ ```yaml
115
+ services:
116
+ pg-mcp:
117
+ image: mrrainx/pg-admin-mcp:pg17
118
+ stdin_open: true
119
+ extra_hosts: ["host.docker.internal:host-gateway"] # harmless on Docker Desktop, required on Linux
120
+ volumes: ["./pg-workspace:/workspace"]
121
+ environment:
122
+ PG_MCP_PROFILES: dev,prod
123
+ PG_MCP_DEFAULT_PROFILE: dev
124
+
125
+ # development: writes and DDL allowed
126
+ PG_DEV_URL: postgres://app:${DEV_PASSWORD}@host.docker.internal:5432/myproject
127
+ PG_DEV_ENV: development
128
+ PG_DEV_ALLOW_WRITE: "true"
129
+ PG_DEV_ALLOW_DDL: "true"
130
+
131
+ # production: no ALLOW_* keys, so it stays read-only
132
+ PG_PROD_URL: postgres://readonly:${PROD_PASSWORD}@prod.internal:5432/myproject
133
+ PG_PROD_ENV: production
134
+ ```
135
+
136
+ `.mcp.json` — commit this byte-identical across every project, never edit it:
137
+
138
+ ```json
139
+ {
140
+ "mcpServers": {
141
+ "pg": {
142
+ "command": "docker",
143
+ "args": [
144
+ "compose", "-f", "${CLAUDE_PROJECT_DIR:-.}/pg_mcp.yml",
145
+ "run", "--rm", "-T", "pg-mcp"
146
+ ]
147
+ }
148
+ }
149
+ }
150
+ ```
151
+
152
+ **`-T`, never `-t`** — the Compose spelling of the same rule as `docker run`: a TTY echoes stdin back
153
+ onto stdout and corrupts the JSON-RPC frame stream this server relies on.
154
+
155
+ **The launch directory is load-bearing.** Verified against Claude Code 2.1.220: `CLAUDE_PROJECT_DIR` is
156
+ not set at the point `.mcp.json` is substituted, so `${CLAUDE_PROJECT_DIR:-.}` always takes its `.`
157
+ fallback, which resolves against the spawned server's CWD — the directory `claude` was launched from,
158
+ not the directory `.mcp.json` lives in and not `$HOME`. The dangerous case is not the obvious failure
159
+ (launching from a directory with no `pg_mcp.yml` — Compose exits, no server); it's launching from a
160
+ *parent* directory that happens to hold its **own** `pg_mcp.yml` (a monorepo root, or any other project
161
+ set up this same way): Compose silently loads that file instead, and the session connects to a
162
+ different project's databases, `prod` profile included, with no error at all. Always start `claude`
163
+ from the project root that holds this `pg_mcp.yml`.
164
+
165
+ Add to the project's `.gitignore`:
166
+
167
+ ```
168
+ .env
169
+ pg-workspace/
170
+ ```
171
+
172
+ The full template — copy-ready files, adding a third profile, the networking table, and how to confirm
173
+ it worked — lives in [`examples/per-project-docker/`](examples/per-project-docker/); start there rather than
174
+ retyping any of the above.
175
+
176
+ ## The 18 tools
177
+
178
+ All tool names are prefixed `postgres_`. "Capability flag" is the `PG_<ALIAS>_ALLOW_*` env var that must be
179
+ `true` on the profile for that tool (or that part of it) to work; a blank cell means no flag is needed.
180
+
181
+ **Discovery** — always available, read-only:
182
+
183
+ | Tool | What it does | Capability flag |
184
+ |---|---|---|
185
+ | `postgres_connection_status` | Resolved config, reachability, server version, current-role privileges, and `pg_dump`/`pg_restore`/`psql` client versions for one profile. | — |
186
+ | `postgres_list_objects` | Paginated listing of schemas, tables, views, materialized views, sequences, functions, enum types, and extensions in scope for a profile. | — |
187
+ | `postgres_describe_object` | Full detail for one object: columns, PK/FK (both directions), unique/check constraints, indexes (incl. partial), triggers, RLS policies. | — |
188
+ | `postgres_get_schema` | Structured live snapshot of a database or a filtered scope (schemas/kinds/name pattern); `detail:"summary"` or full. | — |
189
+
190
+ **Read:**
191
+
192
+ | Tool | What it does | Capability flag |
193
+ |---|---|---|
194
+ | `postgres_select` | Structured, parameterized, paginated (keyset) read of one table or view — the primary way to fetch row data. | — |
195
+ | `postgres_explain` | `EXPLAIN` a statement (TEXT/JSON/XML/YAML, costs/buffers/verbose/settings). `analyze:true` actually executes the statement. | `ALLOW_EXPLAIN_ANALYZE` (only for `analyze:true`) |
196
+ | `postgres_export` | Streams a table selection or a `SELECT` to a CSV/JSON/JSONL file under `/workspace`, batched via a server-side cursor — never buffers the full result. | — |
197
+
198
+ **Write** (the escape hatch plus the batch mutation tools, all destructive-annotated):
199
+
200
+ | Tool | What it does | Capability flag |
201
+ |---|---|---|
202
+ | `postgres_execute_sql` | Arbitrary parameterized SQL — reads always work; `INSERT`/`UPDATE`/`DELETE`/`MERGE` need `ALLOW_WRITE`, `CREATE`/`ALTER`/`DROP`/`TRUNCATE` additionally need `ALLOW_DDL`, `GRANT`/`REVOKE` need `ALLOW_ADMIN`. Mutating statements go through dry-run/confirm. | `ALLOW_WRITE` / `ALLOW_DDL` / `ALLOW_ADMIN` (statement-dependent) |
203
+ | `postgres_insert_batch` | Bulk `INSERT` — set-based `INSERT … SELECT * FROM unnest()`, switching to a bulk strategy above a row threshold. Confirmed on row count alone (no pre-existing rows to checksum). | `ALLOW_WRITE` |
204
+ | `postgres_update_batch` | Filtered bulk `UPDATE`. `where` is a structured, parameterized filter and is mandatory — an empty filter is refused before any SQL reaches the server. Dry-run/confirm. | `ALLOW_WRITE` |
205
+ | `postgres_delete_batch` | Filtered bulk `DELETE`, same mandatory-`where` rule as update. Dry-run/confirm. | `ALLOW_WRITE` |
206
+ | `postgres_copy` | High-throughput whole-table (or column-subset) transfer via PostgreSQL's `COPY` protocol, streamed to/from a file. Export direction is read-only; import direction needs write and goes through dry-run/confirm. | `ALLOW_WRITE` (import direction only) |
207
+
208
+ **Schema:**
209
+
210
+ | Tool | What it does | Capability flag |
211
+ |---|---|---|
212
+ | `postgres_schema_snapshot` | Captures a normalized, byte-stable schema snapshot (tables, views, sequences, functions, enums, extensions) and writes it to a workspace JSON file. | — |
213
+ | `postgres_schema_diff` | Structural diff between two snapshots, each independently `kind:"live"` (fresh capture) or `kind:"file"` (a `postgres_schema_snapshot` output). | — |
214
+ | `postgres_prepare_migration` | Diffs two snapshots and returns an ordered, dependency-safe, risk-annotated SQL plan. **Emits SQL only — never executes it.** | — |
215
+
216
+ **Admin:**
217
+
218
+ | Tool | What it does | Capability flag |
219
+ |---|---|---|
220
+ | `postgres_admin` | Widest blast radius in the server. Six sub-operations selected by `op`: `roles_*` (create/alter/drop a role), `grants_*` (GRANT/REVOKE), `extensions_*` (CREATE/DROP EXTENSION — installing runs arbitrary SQL as superuser), `sessions_*` (cancel/terminate a backend), `maintenance_*` (VACUUM/ANALYZE/REINDEX), `stats_*` (table/index/activity stats). Mutating sub-ops go through dry-run/confirm. `grants_get`, `extensions_list`, and `stats_*` are read-only and need no flag; everything else needs admin. | `ALLOW_ADMIN` (all sub-ops except `grants_get` / `extensions_list` / `stats_*`) |
221
+
222
+ **Backup:**
223
+
224
+ | Tool | What it does | Capability flag |
225
+ |---|---|---|
226
+ | `postgres_backup` | Runs `pg_dump` into a workspace file — whole database (default), or named schemas/tables. Formats: custom (default), directory, tar, plain. Not wrapped in dry-run/confirm (it only reads the database and writes a file); gated by the capability plus the workspace path rules. | `ALLOW_BACKUP` |
227
+ | `postgres_restore` | **The most destructive tool in this server.** `mode:"inspect"` (default) lists archive contents via `pg_restore --list`, touching no database. `mode:"restore"` runs `pg_restore` and can overwrite, or with `clean:true` drop-and-recreate, every object in the archive — no undo, not wrapped in a transaction guard, refused without an explicit `confirm:true`. | `ALLOW_RESTORE` |
228
+
229
+ ## The 3 resources
230
+
231
+ Same read-only catalog listings the discovery tools use, exposed as MCP resources:
232
+
233
+ | URI | Returns |
234
+ |---|---|
235
+ | `postgres://<profile>/schemas` | Every schema in scope for the profile. |
236
+ | `postgres://<profile>/schema/<schema>` | Every object in one schema. |
237
+ | `postgres://<profile>/table/<schema>/<table>` | Full detail for one table-like relation. |
238
+
239
+ ## Safety model
240
+
241
+ **Read-only by default.** A profile with no `ALLOW_*` flags set can only discover, `SELECT`, `EXPLAIN`
242
+ (without `ANALYZE`), export, and diff schemas. Every other capability is a separate, explicit,
243
+ per-profile opt-in:
244
+
245
+ | Flag | Unlocks |
246
+ |---|---|
247
+ | `PG_<ALIAS>_ALLOW_WRITE` | `INSERT`/`UPDATE`/`DELETE`/`MERGE` via `postgres_execute_sql`; `postgres_insert_batch` / `update_batch` / `delete_batch`; `postgres_copy` import direction |
248
+ | `PG_<ALIAS>_ALLOW_DDL` | `CREATE`/`ALTER`/`DROP`/`TRUNCATE` via `postgres_execute_sql` |
249
+ | `PG_<ALIAS>_ALLOW_ADMIN` | `postgres_admin`'s mutating sub-ops (roles, grants, extensions, sessions, maintenance) and `GRANT`/`REVOKE` via `postgres_execute_sql` |
250
+ | `PG_<ALIAS>_ALLOW_EXPLAIN_ANALYZE` | `postgres_explain` with `analyze:true` (this actually executes the statement) |
251
+ | `PG_<ALIAS>_ALLOW_BACKUP` | `postgres_backup` |
252
+ | `PG_<ALIAS>_ALLOW_RESTORE` | `postgres_restore` |
253
+
254
+ All six default to `false`.
255
+
256
+ **Dry-run/confirm protocol.** Every mutating tool shares one parameter fragment:
257
+
258
+ ```ts
259
+ { profile?: string,
260
+ confirm?: boolean, // default false = dry run
261
+ expect?: { sqlHash: string, rowCount: number, keyChecksum?: string },
262
+ allowRowDrift?: number } // default 0
263
+ ```
264
+
265
+ Concrete example, `postgres_delete_batch`:
266
+
267
+ **Call 1 — dry run** (`confirm` omitted). The server resolves identifiers, builds parameterized SQL, checks
268
+ the `where` guard, runs the delete inside a transaction, captures the row count and a checksum of the
269
+ affected primary keys, then rolls back:
270
+
271
+ ```json
272
+ { "profile": "dev", "table": "customers", "where": { "region": { "eq": "closed" } } }
273
+ ```
274
+ →
275
+ ```json
276
+ { "rowCount": 42, "expect": { "sqlHash": "a1b2…", "rowCount": 42, "keyChecksum": "9f3e…" } }
277
+ ```
278
+
279
+ **Call 2 — confirm.** Hand the `expect` object straight back with `confirm: true`. The server recomputes
280
+ `sqlHash` from the incoming arguments — a mismatch is `GUARD_HASH_MISMATCH` — re-checks the row count and
281
+ (if supplied) the key checksum inside the committing transaction, and only then commits:
282
+
283
+ ```json
284
+ { "profile": "dev", "table": "customers", "where": { "region": { "eq": "closed" } },
285
+ "confirm": true, "expect": { "sqlHash": "a1b2…", "rowCount": 42, "keyChecksum": "9f3e…" } }
286
+ ```
287
+
288
+ A concurrent change between the two calls that alters the row count or the key set beyond `allowRowDrift`
289
+ aborts the commit with `GUARD_ROW_DRIFT` instead of silently widening the blast radius.
290
+
291
+ **`envClass: production` makes `expect` (including `keyChecksum`) mandatory and forces `allowRowDrift = 0`.**
292
+ `development` accepts a bare `confirm: true`. `envClass` comes from `PG_<ALIAS>_ENV`; an unset or
293
+ unrecognized value defaults to `production` — the safest failure mode.
294
+
295
+ **Honest limitation.** `keyChecksum` is an MD5 over the sorted *primary keys* of the affected rows, so it
296
+ proves *which* rows a statement touches, never their contents. A row whose non-key columns changed between
297
+ the dry run and the confirm — leaving the primary-key set and the row count identical — is undetectable by
298
+ either check. What `keyChecksum` does catch, that `rowCount` alone does not, is *substitution*: one matching
299
+ row disappearing while a different one starts matching keeps the count identical while changing the key set.
300
+
301
+ ## Configuration
302
+
303
+ `.env.example` is the full, commented contract — copy it to `.env` and fill it in. The tables below are
304
+ what exists, not the full detail.
305
+
306
+ **Global:**
307
+
308
+ | Var | Default |
309
+ |---|---|
310
+ | `PG_MCP_PROFILES` | **required** — csv of profile aliases |
311
+ | `PG_MCP_DEFAULT_PROFILE` | first entry |
312
+ | `PG_MCP_ALLOWED_DIRS` | `/workspace` in the image; empty on a bare host run disables file tools. Comma-separated (Windows `D:\…` paths are fine); a relative entry resolves against the launch directory |
313
+ | `PG_MCP_ENV_FILE` | unset — path to a dotenv file the server loads at startup (relative = launch directory); keys already in the environment win; unreadable file = exit 1 |
314
+ | `PG_MCP_AUDIT_PATH` | unset — audit logging disabled |
315
+ | `PG_MCP_AUDIT_PARAMS` | `false` — redact statement params in the audit log |
316
+ | `PG_MCP_LOG_LEVEL` | `warn` — stderr only, never stdout |
317
+ | `PG_MCP_MAX_ROWS` / `PG_MCP_MAX_BYTES` | `500` / `262144` |
318
+ | `PG_MCP_CONNECT_TIMEOUT_MS` | `10000` — pool checkout timeout; exceeding it is `PROFILE_UNREACHABLE` |
319
+ | `PG_MCP_MAX_IMPORT_BYTES` | `536870912` (512 MiB) — `postgres_copy` import source cap |
320
+ | `PG_MCP_MAX_SNAPSHOT_BYTES` | `67108864` (64 MiB) — snapshot JSON read cap (a real memory bound) |
321
+ | `PG_MCP_MAX_ARCHIVE_BYTES` | `21474836480` (20 GiB) — `pg_dump` archive cap for `postgres_restore` |
322
+ | `PG_MCP_IN_CONTAINER` | set to `1` by the Dockerfile — **never set this yourself** |
323
+ | `PG_MCP_HOST_REWRITE` | unset — opt-in rewrite of loopback hosts, see [Networking](#networking) |
324
+ | `PG_MCP_WORKSPACE_HOST_PATH` | unset — cosmetic host-side path shown in responses |
325
+ | `PG_MCP_PG_BIN_DIR` / `PG_MCP_PG_MAJOR` | resolved from `PATH` / the image's build arg — on a host without the client on `PATH`, e.g. `C:\Program Files\PostgreSQL\17\bin` |
326
+
327
+ **Per profile** (alias uppercased, non-alphanumerics → `_`, prefixed `PG_<ALIAS>_`):
328
+
329
+ | Var | Default |
330
+ |---|---|
331
+ | `URL` | **required** — percent-encode special characters in the password |
332
+ | `ENV` | `production` (`development` \| `staging` \| `production`) |
333
+ | `ALLOW_WRITE` / `ALLOW_DDL` / `ALLOW_ADMIN` / `ALLOW_EXPLAIN_ANALYZE` / `ALLOW_BACKUP` / `ALLOW_RESTORE` | all `false` |
334
+ | `STATEMENT_TIMEOUT_MS` / `LOCK_TIMEOUT_MS` / `IDLE_TX_TIMEOUT_MS` | `30000` / `5000` / `60000` |
335
+ | `POOL_MAX` | `4` |
336
+ | `SCHEMAS` | unset — csv allowlist, unset = all non-system schemas |
337
+ | `ALLOWED_DIRS` / `MAX_ROWS` | inherit global |
338
+ | `SSL_MODE` / `SSL_ROOT_CERT` | unset |
339
+ | `EXPORT_TIMEOUT_MAX_MS` | `600000` (10 min) — ceiling for `postgres_export` / `postgres_copy`'s per-call `timeoutMs` |
340
+ | `BACKUP_TIMEOUT_MAX_MS` | `3600000` (1 hour) — ceiling for the `pg_dump`/`pg_restore` child process, deliberately separate from `STATEMENT_TIMEOUT_MS` |
341
+
342
+ A broken profile does not stop the server at startup — other profiles stay usable, and
343
+ `postgres_connection_status` reports the breakage for the affected alias.
344
+
345
+ ## Connecting a client
346
+
347
+ Every client below takes the same stdio `command`/`args` shape. **npx (primary):** `"command": "npx"`,
348
+ `"args": ["-y", "pg-admin-mcp"]` plus an `env` block — the per-project `.mcp.json` in
349
+ [`examples/per-project/`](examples/per-project/) is the complete Claude Code form. On native Windows,
350
+ `npx` is a `.cmd` shim that most clients cannot spawn directly: use `"command": "cmd"`,
351
+ `"args": ["/c", "npx", "-y", "pg-admin-mcp"]`. Clients launched outside a project (Claude Desktop) have
352
+ no meaningful launch directory, so give `PG_MCP_ENV_FILE` and `PG_MCP_ALLOWED_DIRS` absolute paths there.
353
+
354
+ The Docker forms (alternative) follow.
355
+
356
+ ### Claude Code
357
+
358
+ Verified against Claude Code 2.1.220 (`claude mcp add --help`). Stdio is the default transport, so it does
359
+ not need to be specified:
360
+
361
+ ```bash
362
+ claude mcp add pg-admin-mcp -- docker run --rm -i --init \
363
+ --add-host=host.docker.internal:host-gateway \
364
+ --env-file /absolute/path/to/.env \
365
+ -v /absolute/path/to/pg-workspace:/workspace \
366
+ mrrainx/pg-admin-mcp:pg17
367
+ ```
368
+
369
+ This writes an entry shaped like this into Claude Code's config (`~/.claude.json`, under `mcpServers`):
370
+
371
+ ```json
372
+ {
373
+ "mcpServers": {
374
+ "pg-admin-mcp": {
375
+ "type": "stdio",
376
+ "command": "docker",
377
+ "args": [
378
+ "run", "--rm", "-i", "--init",
379
+ "--add-host=host.docker.internal:host-gateway",
380
+ "--env-file", "/absolute/path/to/.env",
381
+ "-v", "/absolute/path/to/pg-workspace:/workspace",
382
+ "mrrainx/pg-admin-mcp:pg17"
383
+ ],
384
+ "env": {}
385
+ }
386
+ }
387
+ }
388
+ ```
389
+
390
+ Use absolute paths for `--env-file` and the volume mount — relative paths are resolved against wherever
391
+ the client process's cwd happens to be, not this repo.
392
+
393
+ ### Claude Desktop
394
+
395
+ Edit `claude_desktop_config.json` directly:
396
+
397
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
398
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
399
+
400
+ ```json
401
+ {
402
+ "mcpServers": {
403
+ "pg-admin-mcp": {
404
+ "command": "docker",
405
+ "args": [
406
+ "run", "--rm", "-i", "--init",
407
+ "--add-host=host.docker.internal:host-gateway",
408
+ "--env-file", "/absolute/path/to/.env",
409
+ "-v", "/absolute/path/to/pg-workspace:/workspace",
410
+ "mrrainx/pg-admin-mcp:pg17"
411
+ ]
412
+ }
413
+ }
414
+ }
415
+ ```
416
+
417
+ Check your Claude Desktop version's docs for this exact key set — not verified locally against Claude
418
+ Desktop, only against the standard stdio-server JSON shape.
419
+
420
+ ### Codex CLI
421
+
422
+ `~/.codex/config.toml`:
423
+
424
+ ```toml
425
+ [mcp_servers.pg-admin-mcp]
426
+ command = "docker"
427
+ args = [
428
+ "run", "--rm", "-i", "--init",
429
+ "--add-host=host.docker.internal:host-gateway",
430
+ "--env-file", "/absolute/path/to/.env",
431
+ "-v", "/absolute/path/to/pg-workspace:/workspace",
432
+ "mrrainx/pg-admin-mcp:pg17",
433
+ ]
434
+ ```
435
+
436
+ Not verified locally against the Codex CLI — this is the standard `[mcp_servers.<name>]` stdio form; check
437
+ `codex --help` / the Codex docs for your installed version before relying on it.
438
+
439
+ ### Cursor / VS Code
440
+
441
+ Both use the same `command`/`args` stdio shape as Claude Desktop, in a project-local file:
442
+
443
+ - Cursor: `.cursor/mcp.json`, top-level key `mcpServers`
444
+ - VS Code: `.vscode/mcp.json`, top-level key `servers`
445
+
446
+ ```json
447
+ {
448
+ "pg-admin-mcp": {
449
+ "command": "docker",
450
+ "args": [
451
+ "run", "--rm", "-i", "--init",
452
+ "--add-host=host.docker.internal:host-gateway",
453
+ "--env-file", "/absolute/path/to/.env",
454
+ "-v", "/absolute/path/to/pg-workspace:/workspace",
455
+ "mrrainx/pg-admin-mcp:pg17"
456
+ ]
457
+ }
458
+ }
459
+ ```
460
+
461
+ Not verified locally against Cursor or VS Code — check each editor's MCP docs for the current top-level
462
+ key and file location before relying on it.
463
+
464
+ ## Networking
465
+
466
+ **npx / host mode:** the server is an ordinary process on your machine, so URL hosts mean what they
467
+ mean there — `localhost` for a local database (or a container's published port), the hostname for a
468
+ remote one. Nothing below applies.
469
+
470
+ **Docker:** the DB's location relative to the container decides the URL host and any extra `docker run` flags:
471
+
472
+ | DB location | Extra run flags | URL host |
473
+ |---|---|---|
474
+ | Host machine, Docker Desktop (Windows/macOS) | none | `host.docker.internal` |
475
+ | Host machine, Linux | `--add-host=host.docker.internal:host-gateway` | `host.docker.internal` |
476
+ | Another container | `--network <shared_network>` | that container's service name |
477
+
478
+ `localhost` inside the container **is the container** — not your host. The server does not silently rewrite
479
+ a loopback host to something that might work, because that could connect the agent to a different database
480
+ than the one it thinks it named. Instead it fails fast with `PROFILE_UNREACHABLE`, reported both from
481
+ `postgres_connection_status` before any connection attempt and from the error path on a runtime
482
+ `ECONNREFUSED`/`ENOTFOUND`. Opt-in rewrite is available via `PG_MCP_HOST_REWRITE=host.docker.internal`
483
+ (applied only to loopback hosts, always reported in the response) if you want the old silent behavior.
484
+
485
+ ## The `/workspace` volume
486
+
487
+ One mounted volume, `-v <HOST>/pg-workspace:/workspace`. Every tool that writes a file —
488
+ `postgres_export`, `postgres_backup`, `postgres_schema_snapshot`, `postgres_copy` (export direction),
489
+ and the audit log (`PG_MCP_AUDIT_PATH`) — writes inside it, and nothing outside `PG_MCP_ALLOWED_DIRS` is
490
+ reachable at all.
491
+
492
+ **Path rule:** a relative path resolves against the profile's first allowed directory (so `dumps/x.dump`
493
+ means `/workspace/dumps/x.dump`); an absolute path is normalized and its parent directory must still land
494
+ inside an allowed directory. Files are written as `<name>.partial` and renamed on success, so a killed
495
+ `pg_dump`/export never leaves a plausible-looking truncated file under the real name.
496
+
497
+ **Linux ownership note.** The container always runs as uid `10001` (never root — see the `Dockerfile`).
498
+ On Docker Desktop (Windows/macOS) the bind-mount boundary maps every uid to full access, so this is
499
+ cosmetic. **On native Linux it is not cosmetic — it is a real `EACCES` risk.** `./pg-workspace` ends up
500
+ owned by whichever uid created it first: your own, if you ran `mkdir -p pg-workspace` first (Quick
501
+ start step 3); `root:root` at mode `0755`, if you skipped that and let the Docker daemon auto-create
502
+ the missing bind-mount source. Either way, uid `10001` is neither the owner nor (normally) in the
503
+ owning group, and the default `0755` grants no write to anyone else — so the very first
504
+ `postgres_export`/`postgres_backup`/`postgres_schema_snapshot` call fails with `EACCES` writing into a
505
+ directory that, from the host side, looks perfectly normal and owned by you. The fix is the same one
506
+ that solves the ownership question above — run the container as your own uid/gid instead of `10001`:
507
+
508
+ ```bash
509
+ docker run --rm -i --init --user "$(id -u):$(id -g)" \
510
+ --add-host=host.docker.internal:host-gateway \
511
+ --env-file /absolute/path/to/.env \
512
+ -v /absolute/path/to/pg-workspace:/workspace \
513
+ mrrainx/pg-admin-mcp:pg17
514
+ ```
515
+
516
+ For the Compose form (`pg_mcp.yml`, see [Using it from several projects](#using-it-from-several-projects)),
517
+ the equivalent is a `user:` key on the `pg-mcp` service — plain `$UID`/`$GID` are bash builtins and are
518
+ **not** exported to `docker compose` by default, so export them under different names first:
519
+
520
+ ```bash
521
+ export DOCKER_UID="$(id -u)" DOCKER_GID="$(id -g)"
522
+ docker compose -f pg_mcp.yml run --rm -T pg-mcp
523
+ ```
524
+
525
+ ```yaml
526
+ services:
527
+ pg-mcp:
528
+ image: mrrainx/pg-admin-mcp:pg17
529
+ user: "${DOCKER_UID:-10001}:${DOCKER_GID:-10001}"
530
+ # ... rest unchanged
531
+ ```
532
+
533
+ Not applied by default in [`examples/per-project-docker/pg_mcp.yml`](examples/per-project-docker/pg_mcp.yml) — the
534
+ key is a no-op on Docker Desktop and would need every Linux user to remember to export
535
+ `DOCKER_UID`/`DOCKER_GID` first, so it stays opt-in text here rather than a template default that fails
536
+ open to `10001:10001` for anyone who forgets.
537
+
538
+ ## Troubleshooting
539
+
540
+ - **`PROFILE_UNREACHABLE` pointing at `localhost`.** You configured `PG_<ALIAS>_URL` with `localhost` or
541
+ `127.0.0.1` — inside the container that's the container itself. See [Networking](#networking).
542
+ - **A password with `#` gets silently truncated.** Docker's `--env-file` does no quote stripping and no
543
+ interpolation — a value is taken literally from the first `=` to end of line, and a `#` anywhere starts a
544
+ comment. Percent-encode special characters in the connection URL (`p@ss#1` → `p%40ss%231`).
545
+ - **Never pass `-t` to `docker run`.** A TTY echoes input back onto stdout and corrupts the JSON-RPC frame
546
+ stream this server relies on. Use `-i` only.
547
+ - **`BINARY_MISSING`.** `postgres_backup`/`postgres_restore` need `pg_dump`/`pg_restore` on `PATH` (or
548
+ `PG_MCP_PG_BIN_DIR`). Resolution is lazy — the other 16 tools work with no client binaries installed at
549
+ all; this error only ever comes from the two backup/restore tools. In host mode install a client
550
+ matching your server's major (Windows `winget install PostgreSQL.PostgreSQL.17`, Debian/Ubuntu
551
+ `sudo apt install postgresql-client`, macOS `brew install libpq`) and point `PG_MCP_PG_BIN_DIR` at its
552
+ `bin` if it is not on `PATH` (Windows: `C:\Program Files\PostgreSQL\17\bin`).
553
+ - **npx server exits immediately with `PG_MCP_ENV_FILE points to …`.** The `.env` it names does not
554
+ exist at that path. A relative path resolves against the directory the client was launched from —
555
+ start `claude` from the project root, or make the path absolute.
556
+ - **`pg_dump`/`pg_restore` major-version mismatch.** Archives are forward-only: a client older than the
557
+ server it dumped from refuses to run, and an archive produced by a newer `pg_dump` cannot be restored by
558
+ an older `pg_restore`. Use the image tag matching your PostgreSQL major (`:pg17`, `:pg16`, …) —
559
+ `postgres_restore` checks this before spawning anything and names the required tag in the refusal.
560
+
561
+ ## Known limitations
562
+
563
+ - `postgres_restore` only accepts single-file archives (`pg_dump --format=custom` or `--format=tar`).
564
+ **Directory-format archives are not supported** — they cannot be identity-checked as one file.
565
+ - `postgres_restore` cannot restore **plain-format (SQL text) dumps** — those aren't `pg_restore` archives;
566
+ feed them to `psql` instead.
567
+ - `keyChecksum` in the dry-run/confirm protocol pins *which* rows a statement touches (by primary key), not
568
+ their contents — see [Safety model](#safety-model) for the exact case it does and doesn't catch.
569
+
570
+ ## Development
571
+
572
+ Package manager is **pnpm** — never npm.
573
+
574
+ ```bash
575
+ pnpm install
576
+ pnpm check # lint + typecheck + unit tests + build + stdio smoke check
577
+ pnpm test # unit tests only (no Docker)
578
+ pnpm test:int # integration tests against a real postgres:17 via testcontainers (needs Docker)
579
+ RUN_CONTAINER_SMOKE=1 pnpm test:smoke # builds the image and drives it over stdio (needs Docker)
580
+ ```
581
+
582
+ Three Vitest projects: `unit` needs nothing but Node; `integration` needs Docker (testcontainers starts
583
+ `postgres:17` on a random port); `smoke` needs Docker and is opt-in (`RUN_CONTAINER_SMOKE=1`, ~300s) because
584
+ it builds the image from scratch. **The suite runs on the host, never inside the MCP image** — testing
585
+ in-image would need Docker-in-Docker for testcontainers for no gain.
586
+
587
+ ### Building the image locally
588
+
589
+ The published image comes from Docker Hub (see [Quick start (Docker, alternative)](#quick-start-docker-alternative)) — build locally only when
590
+ testing a change before it ships. Client binaries are pinned to a PostgreSQL major; pass the `PG_MAJOR`
591
+ matching your server and tag accordingly:
592
+
593
+ ```bash
594
+ docker build --build-arg PG_MAJOR=17 -t pg-admin-mcp:pg17 .
595
+ ```
596
+
597
+ See [docs/releasing.md](docs/releasing.md) for how a tagged commit turns this into the published
598
+ `mrrainx/pg-admin-mcp:pg17`/`:pg16` images (`linux/amd64` only).
599
+
600
+ ## Status
601
+
602
+ Personal project, version `0.1.0`. Distributed on npm as `pg-admin-mcp` (`npx -y pg-admin-mcp`) — if
603
+ `npx` reports `404 Not Found`, no npm release has been cut yet; until then run it from a clone
604
+ (`pnpm install && pnpm build`, then `node /absolute/path/to/dist/index.js` as the client `command`). Docker images are
605
+ built and pushed to Docker Hub as `mrrainx/pg-admin-mcp` (`:pg17`, `:pg16`, `linux/amd64` only) by the
606
+ release workflow described in [docs/releasing.md](docs/releasing.md), triggered by pushing a `v*` tag —
607
+ if no such tag has been pushed yet, `docker pull` returns `manifest unknown` and
608
+ [Building the image locally](#building-the-image-locally) is the only way to get the image. No license
609
+ file yet.