@yawlabs/postgres-mcp 0.9.1 → 0.11.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,217 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.0] - 2026-08-23
11
+
12
+ ### Added
13
+
14
+ - **`pg_io_stats`, a new tool for I/O observability.** `pg_stat_io` read /
15
+ write / extend / fsync counts, bytes and times per backend type and context
16
+ (PG16+), plus in-flight async I/O handles from `pg_aios` and the active
17
+ `io_method` (PG18+). Byte accounting differs by server: PG16-17 expose
18
+ `op_bytes` to multiply against operation counts, and PG18 removed it in favour
19
+ of direct `read_bytes` / `write_bytes` / `extend_bytes`. Both branches are
20
+ normalized to one figure, and neither ever names the other's column.
21
+
22
+ - **Server-version gating.** `getServerVersionNum()` in `api.ts` caches
23
+ `server_version_num` per process and every version-dependent column now routes
24
+ through it. A failed probe returns 0, the "assume oldest" sentinel, so an
25
+ unknown server falls through to the conservative query rather than emitting
26
+ SQL referencing a column it may not have. Probe failures are deliberately not
27
+ cached: one transient blip during the first tool call would otherwise pin the
28
+ process to degraded output for its whole lifetime.
29
+
30
+ - **`pg_describe_table` flags generated and identity columns.** `pg_attrdef`
31
+ stores generation expressions alongside plain defaults, so a generated
32
+ column's expression was surfacing as `default_value` with nothing marking it
33
+ -- an agent read that as "optional column, has a default" and wrote an INSERT
34
+ postgres rejects. Identity columns failed the other way: no `pg_attrdef` row
35
+ at all, so they reported `default_value: null, nullable: false` and the agent
36
+ supplied a value into `GENERATED ALWAYS AS IDENTITY`. Columns now carry
37
+ `generated`, `identity`, and `generation_expression`.
38
+
39
+ - **PG18 constraint metadata in `pg_describe_table`.** Constraints carry
40
+ `validated` on every version, plus `enforced` and `has_period` on PG18+. A
41
+ `NOT ENFORCED` foreign key looks present but validates nothing, and a temporal
42
+ `WITHOUT OVERLAPS` primary key previously rendered as an ordinary one. PG18
43
+ also made NOT NULL a real `pg_constraint` row that can be `NOT VALID` -- its
44
+ `attnotnull` docs now read "possibly invalid" -- so `not_null_validated` is
45
+ reported on PG18+, where the question can arise.
46
+
47
+ - **`pg_explain` gained the planner options that actually diagnose a slow
48
+ query**: `buffers`, `settings`, `verbose`, `wal`, `costs`, `timing`, plus
49
+ `generic_plan` (PG16+) for planning a parameterized query with no values, and
50
+ `memory` / `serialize` (PG17+). Options below the server's version are
51
+ rejected up front by name and required major, rather than as a parse error
52
+ from the server.
53
+
54
+ - **`pg_health` reports what a health check needs.** `max_connections` and a
55
+ used-fraction (so a connection count is readable), `wait_event_type` /
56
+ `wait_event` / `backend_type` / transaction age on active queries, and a
57
+ `pg_stat_database` rollup: deadlocks, temp files and bytes, conflicts, cache
58
+ hit ratio, `stats_reset`.
59
+
60
+ - **`pg_advisor` checks wraparound risk**, the classic pageable incident:
61
+ per-database and per-table `age(relfrozenxid)` measured against
62
+ `autovacuum_freeze_max_age`, with a `wraparoundThreshold` parameter mirroring
63
+ the existing sequence-exhaustion one. Freeze coverage via `relallfrozen` on
64
+ PG18+.
65
+
66
+ Multixact wraparound is checked alongside it, on the same rows and with no
67
+ extra round trip: `mxid_age(relminmxid)` against
68
+ `autovacuum_multixact_freeze_max_age`. Multixacts are consumed by row-level
69
+ locking (`SELECT ... FOR SHARE/UPDATE`, foreign-key checks), so a lock-heavy
70
+ workload can exhaust them while `relfrozenxid` still looks perfectly healthy
71
+ -- checking only xids reports such a cluster as clean. A row is flagged when
72
+ EITHER counter crosses the threshold, and `triggered_by` (`xid` /
73
+ `multixact` / `both`) says which, because the remediation differs.
74
+
75
+ - **`pg_health` connection buckets now reconcile against `total`.** Two
76
+ separate ways the old breakdown lied. First, `pg_stat_activity` does not hide
77
+ other users' sessions from an unprivileged role -- it returns the rows with
78
+ `state` NULL -- so `total` was complete while `active` / `idle` counted only
79
+ the caller's own sessions, and an operator could read `active: 0` on a busy
80
+ database. A `state_unavailable` counter now makes that shortfall visible.
81
+ Second, `idle in transaction (aborted)`, `starting`, `fastpath function
82
+ call`, and `disabled` matched no filter and vanished from the breakdown; the
83
+ aborted state is the one that holds locks and blocks vacuum, so it gets its
84
+ own field, and an `other` catch-all absorbs the rest (a `NOT IN` list, so a
85
+ future major's new state cannot silently disappear again).
86
+
87
+ - **`POSTGRES_APPLICATION_NAME`** (default `postgres-mcp`), so agent traffic is
88
+ identifiable in `pg_stat_activity` instead of anonymous -- while `pg_health`
89
+ itself reports `application_name` for every other session. An
90
+ `application_name` in `DATABASE_URL` still wins.
91
+
92
+ ### Changed
93
+
94
+ - **BREAKING: `pg_seq_scan_tables` and `pg_unused_indexes` return an envelope,
95
+ not a bare row array.** `data` is now
96
+ `{rows, stats_reset, stats_reset_age_seconds}`; callers read `data.rows`.
97
+ The reason is `pg_unused_indexes` could tell an agent to drop a load-bearing
98
+ index: a scan count is meaningless without knowing when the counters were
99
+ reset, and if that happened an hour ago every index looks unused. Both tools
100
+ also report `last_idx_scan` / `last_seq_scan` on PG16+, where "not scanned
101
+ since March" beats a bare counter.
102
+
103
+ - **BREAKING: `pg_top_queries` returns the same envelope**, for the same
104
+ reason -- it ranks cumulative `total_exec_time` / `calls`. Its clock is NOT
105
+ `pg_stat_database.stats_reset` but `pg_stat_statements_info.stats_reset`, a
106
+ genuinely independent reset point; using the wrong one would have been worse
107
+ than omitting it. The same view supplies `dealloc`, which is the subtler
108
+ trap: it counts how often entries for the least-executed statements were
109
+ evicted for exceeding `pg_stat_statements.max`, so a non-zero value means
110
+ the "top queries" ranking is drawn from an incomplete population. Both
111
+ require extension 1.9 (PostgreSQL 14) and are omitted below it rather than
112
+ returned as nulls that would read as "never reset".
113
+
114
+ - **BREAKING: `pg_explain` with `analyze: true` now emits `BUFFERS`.** PG18
115
+ turns it on by default server-side; on PG15-17 it had to be requested and was
116
+ absent. Plans gain buffer lines, so text plans roughly double in length and
117
+ `POSTGRES_MAX_ROWS` truncation can fire where it previously did not. Pass
118
+ `buffers: false` for the old output.
119
+
120
+ - **BREAKING: the Node floor is now 22.** Node 20 reached end of life; the
121
+ supported lines are 22, 24 and 26. The esbuild target deliberately stays at
122
+ `node20` -- it only controls syntax downleveling, so a lower floor keeps the
123
+ bundle runnable under alternate runtimes.
124
+
125
+ - **Tools register through `registerTool` instead of `server.tool`.** All six
126
+ `server.tool` overloads are deprecated as of SDK 1.30 and are gone in the v2
127
+ packages. `registerTool` is also the only form that can carry `outputSchema`,
128
+ so this is what makes structured tool output reachable later. Tools now
129
+ advertise a top-level `title` as well as `annotations.title`; both are emitted,
130
+ since dropping either regresses hosts that read only one.
131
+
132
+ - **`@modelcontextprotocol/sdk` 1.29.0 -> 1.30.0** (the final v1.x release) and
133
+ **`pg` ^8.14.0 -> ^8.23.0**. The pg bump makes `sslnegotiation=direct`
134
+ available in `DATABASE_URL`, which skips a round trip against PG17+ servers;
135
+ it stays opt-in because a PG16-or-older server rejects the connection.
136
+
137
+ - **Documented where PostgreSQL support actually sits.** PG13 reached end of
138
+ life on 2025-11-13 and PG14 does so on 2026-11-12. The integration matrix
139
+ remains 15 / 17 / 18.
140
+
141
+ ### Fixed
142
+
143
+ - **`POSTGRES_APPLICATION_NAME` was missing from the oam sandbox allowlist.**
144
+ Under `POSTGRES_MCP_SANDBOX=1`, oam removes an undeclared variable from
145
+ `process.env` rather than denying access, so the operator's configured name
146
+ would have silently vanished and the server would have reported the default
147
+ -- precisely the silent-misbehaviour failure the launcher's own comment warns
148
+ about. `PGAPPNAME` is now granted too, since the pg driver reads it as its
149
+ own fallback for the same setting. A new test scans the shipped bundle for
150
+ literal `process.env` reads and fails if any config variable is absent from
151
+ the allowlist, so the next one cannot ship silently.
152
+
153
+ - **`pg_io_stats` had no test coverage at all.** `tools.test.ts` builds its own
154
+ `allTools` array separate from `index.ts`, and the new tool was added to one
155
+ and not the other -- exempting it from every structural check, including the
156
+ duplicate-name guard. Both arrays now agree, and the tool has a unit suite
157
+ covering its version branches.
158
+
159
+ - **A version probe suspended across `shutdown()` could republish a stale
160
+ server version.** `shutdown()` clears the cache, but a probe already awaiting
161
+ its query would resolve afterwards and write the OLD server's version into
162
+ the cache the NEW pool uses -- gating catalog queries against the wrong
163
+ server, the exact failure the reset exists to prevent. A generation counter
164
+ now invalidates in-flight probes, matching the guard `resolveTypeNames`
165
+ already had.
166
+
167
+ - **Four `pg_explain` tests asserted the wrong thing whenever `DATABASE_URL`
168
+ was set.** Their helper cleared the env var to force the version probe's
169
+ "unknown" sentinel, but the pool and the version cache are module-scoped and
170
+ survive that, so on any machine with `DATABASE_URL` exported the probe never
171
+ re-ran. The suite passed or failed depending on the developer's environment.
172
+ The helper now calls `shutdown()` on both edges.
173
+
174
+ ### Security
175
+
176
+ - **`fast-uri` bumped past the host-confusion advisory (GHSA, high).** It
177
+ reaches the published artifact rather than staying a build-time concern: the
178
+ MCP SDK depends on `ajv`, which depends on `fast-uri`, and esbuild bundles the
179
+ whole graph into `dist/index.js`. "It is only a devDependency" is not the
180
+ right test for this package -- the bundle is what ships, and the dependency
181
+ tree is flattened into it. `npm audit` now reports zero vulnerabilities.
182
+
183
+ ## [0.10.0] - 2026-08-08
184
+
185
+ ### Added
186
+
187
+ - **An opt-in `--permission` sandbox under oam**, via `POSTGRES_MCP_SANDBOX=1`.
188
+ The network grant is derived from `DATABASE_URL` at launch rather than
189
+ hardcoded, so the one endpoint the server may reach is the one it was
190
+ configured to reach. Host and port are both pinned, because oam matches grants
191
+ by prefix and a bare host would also admit every other port on it. Filesystem
192
+ and child-process are denied outright.
193
+
194
+ Opt-in rather than default because a wrong grant does not fail loudly: oam
195
+ denies a non-granted environment variable by making it **absent** from
196
+ `process.env` rather than throwing, so an under-granted `DATABASE_URL` would
197
+ read as "not configured" instead of "denied". The environment allow-list is
198
+ derived from what the shipped bundle actually reads, which is why it includes
199
+ the pg driver's own lookups (`PGSSLMODE`, `PGCONNECT_TIMEOUT` and friends) that
200
+ a hand-written list would have missed.
201
+
202
+ ### Changed
203
+
204
+ - **oam 0.9.0 is now the minimum**, enforced in `bin/postgres-mcp.mjs`. Older
205
+ releases ran `child_process.execFile` arguments through a shell, accepted
206
+ `exec`'s `timeout` and ignored it, truncated `spawnSync` at `maxBuffer` while
207
+ reporting success, and treated `stdio: 'inherit'` as `'pipe'`. This server
208
+ spawns nothing, so the floor is enforced for consistency across
209
+ `@yawlabs/*-mcp` rather than because this launcher was exposed. An older oam is
210
+ not an error: the launcher falls back to Node and says so on stderr, and
211
+ `POSTGRES_MCP_RUNTIME=oam` turns that into a hard error.
212
+
213
+ ### Fixed
214
+
215
+ - **`release.sh` aborted instead of releasing when `[Unreleased]` was empty.**
216
+ The body extraction pipes through `grep -v` to drop blank lines, and `grep`
217
+ exits non-zero when it matches nothing — so under `set -e` an empty section
218
+ killed the script at that line, and the `warn` branch written to handle
219
+ exactly that case could never run.
220
+
10
221
  ## [0.9.1] - 2026-08-07
11
222
 
12
223
  ### Fixed
package/README.md CHANGED
@@ -167,22 +167,23 @@ The bigger leverage is multi-tool reasoning. A few real workflows:
167
167
  | `pg_query` | Run a SQL query. Writes gated by the role in `DATABASE_URL` first, `ALLOW_WRITES` second. Supports parameterized queries via `params`. Result fields include `dataTypeName` (e.g. `int4`, `jsonb`) alongside `dataTypeID`. |
168
168
  | `pg_list_schemas` | List non-system schemas. |
169
169
  | `pg_list_tables` | List tables (and optionally views) in a schema with estimated row counts. Paginated via `limit`/`offset`. |
170
- | `pg_describe_table` | Kind, columns, PK, outgoing FKs, incoming FKs (`referenced_by`), CHECK / UNIQUE / EXCLUDE constraints, indexes, and partition parent/children for a relation. |
170
+ | `pg_describe_table` | Kind, columns, PK, outgoing FKs, incoming FKs (`referenced_by`), CHECK / UNIQUE / EXCLUDE constraints, indexes, and partition parent/children for a relation. Generated and identity columns are flagged (`generated`, `identity`, `generation_expression`) so an agent doesn't try to write to them. Constraints carry `validated`, plus `enforced` / `has_period` on PG18+. |
171
171
  | `pg_list_views` | List views and materialized views in a schema, including their SQL definitions. |
172
172
  | `pg_list_functions` | List functions, procedures, and aggregates in a schema with signatures and return types. |
173
173
  | `pg_list_extensions` | List installed extensions (pgvector, postgis, pg_stat_statements, etc.) with versions. |
174
174
  | `pg_search_columns` | Find columns by name pattern across all user schemas. Case-insensitive, supports SQL LIKE wildcards. |
175
- | `pg_explain` | `EXPLAIN` or `EXPLAIN ANALYZE` for a SQL statement. Text or JSON output. Optional `hypothetical_indexes` (requires the [HypoPG](https://github.com/HypoPG/hypopg) extension) lets you ask "what would the plan be with these indexes?" without creating them on disk. |
176
- | `pg_health` | Server version, database size, connection count, active queries, table count. |
177
- | `pg_top_queries` | Top N queries by total/mean execution time. Requires the `pg_stat_statements` extension. |
178
- | `pg_seq_scan_tables` | Tables with heavy sequential scans - missing-index candidates. |
179
- | `pg_unused_indexes` | Non-unique, non-primary indexes with low scan counts - drop candidates. |
175
+ | `pg_explain` | `EXPLAIN` or `EXPLAIN ANALYZE` for a SQL statement. Text or JSON output. Planner options: `buffers` (on by default with `analyze`), `settings`, `verbose`, `wal`, `costs`, `timing`, plus `generic_plan` (PG16+, plan a parameterized query with no values) and `memory` / `serialize` (PG17+). Optional `hypothetical_indexes` (requires the [HypoPG](https://github.com/HypoPG/hypopg) extension) lets you ask "what would the plan be with these indexes?" without creating them on disk. |
176
+ | `pg_health` | Server version, database size, connections against `max_connections`, active queries with wait events and transaction age, `pg_stat_database` rollup (deadlocks, temp files, cache hit ratio), table count. |
177
+ | `pg_top_queries` | Top N queries by total/mean execution time. Requires the `pg_stat_statements` extension. Returns `stats_reset` (from `pg_stat_statements_info`, a different clock from the other stats tools) and `dealloc` on extension 1.9+ - a non-zero `dealloc` means entries were evicted past `pg_stat_statements.max`, so the ranking is drawn from an incomplete population. |
178
+ | `pg_seq_scan_tables` | Tables with heavy sequential scans - missing-index candidates. Returns the `stats_reset` window alongside the rows, since the counters mean nothing without it. `last_seq_scan` / `last_idx_scan` on PG16+. |
179
+ | `pg_unused_indexes` | Non-unique, non-primary indexes with low scan counts - drop candidates. Also returns `stats_reset`: a recently reset counter makes every index look unused, which is how a load-bearing index gets dropped. `last_idx_scan` on PG16+. |
180
+ | `pg_io_stats` | I/O observability: `pg_stat_io` read/write/extend/fsync counts, bytes and times per backend type and context (PG16+), plus in-flight async I/O handles from `pg_aios` and the active `io_method` (PG18+). |
180
181
  | `pg_inspect_locks` | Who is blocking whom right now (blocked PID, blocker PID, lock type, queries). |
181
182
  | `pg_list_roles` | Database roles with login/superuser/createdb flags and group memberships. |
182
183
  | `pg_table_privileges` | Who has SELECT/INSERT/UPDATE/DELETE/etc. on a table or whole schema. |
183
184
  | `pg_table_bloat` | Tables with high dead-tuple ratios - VACUUM candidates. |
184
185
  | `pg_replication_status` | Replication slots, connected replicas, and current WAL position. |
185
- | `pg_advisor` | Rolled-up DBA lints in one call: sequence-exhaustion candidates, tables without a primary key, and (configurable) public tables with RLS disabled. The "what should I be looking at?" starting point. |
186
+ | `pg_advisor` | Rolled-up DBA lints in one call: sequence-exhaustion candidates, wraparound risk for both counters (per-database and per-table `age(relfrozenxid)` against `autovacuum_freeze_max_age`, and `mxid_age(relminmxid)` against `autovacuum_multixact_freeze_max_age` -- a lock-heavy workload can exhaust multixacts while xids look healthy; `triggered_by` says which), tables without a primary key, and (configurable) public tables with RLS disabled. The "what should I be looking at?" starting point. |
186
187
  | `pg_kill` | Cancel a running query or terminate a backend connection. Requires `ALLOW_WRITES=1`. |
187
188
 
188
189
  ## Configuration
@@ -198,12 +199,25 @@ All env vars are read from the MCP server's environment:
198
199
  | `POSTGRES_MAX_ROWS` | `1000` | Cap on rows returned by `pg_query`. |
199
200
  | `POSTGRES_POOL_MAX` | `5` | Max pool connections. Set to `1` for single-threaded backends (pglite-socket, PgBouncer transaction mode). |
200
201
  | `POSTGRES_SSL_REJECT_UNAUTHORIZED` | unset | Set to `false` to skip TLS cert verification (for managed DBs using private-CA certs). Connection is still encrypted. |
202
+ | `POSTGRES_APPLICATION_NAME` | `postgres-mcp` | Value reported in `pg_stat_activity.application_name`, so agent traffic is identifiable to whoever is watching the database. An `application_name` in `DATABASE_URL` takes precedence over this. |
201
203
  | `POSTGRES_MCP_RUNTIME` | `auto` | Which JS runtime executes the server: `auto` (prefer [oam](https://oamjs.org), fall back to Node), `oam` (require oam, fail if absent), `node` (never use oam). See [Runtime](#runtime). |
202
204
  | `OAM_BIN` | unset | Explicit path to an `oam` binary, checked before PATH and the default install locations. |
203
205
 
204
206
  ### Supported Postgres versions
205
207
 
206
- Tested on **PostgreSQL 15, 17 and 18** in the integration matrix. Should work on PG13+ -- a few tools (`pg_replication_status` reading `wal_status`, `pg_top_queries` reading `*_exec_time`) rely on columns that landed in PG13. PG12 and below are out of upstream support and not exercised here.
208
+ Tested on **PostgreSQL 15, 17 and 18** in the integration matrix.
209
+
210
+ Works on PG13+, but note where upstream support actually sits: **PG13 reached end of life on 2025-11-13 and PG14 does so on 2026-11-12.** PG13/14 are not exercised here and are not a compatibility target going forward. PG12 and below are further out of support and some tools rely on columns that landed in PG13 (`pg_replication_status` reading `wal_status`, `pg_top_queries` reading `*_exec_time`).
211
+
212
+ Newer server versions unlock extra fields rather than being required. Every version-dependent column is gated on `server_version_num` and simply omitted on servers that predate it, so nothing errors -- you get a slightly thinner answer. The cut points that matter:
213
+
214
+ | Server | What it adds |
215
+ |--------|--------------|
216
+ | PG16+ | `last_idx_scan` / `last_seq_scan` in the stats tools (index/table staleness rather than a bare counter), `pg_explain` `generic_plan` |
217
+ | PG17+ | `pg_explain` `memory` and `serialize` |
218
+ | PG18+ | Generated-column form (`stored` vs `virtual`), NOT NULL constraint validity, `conenforced` / `conperiod` constraint metadata in `pg_describe_table`, `relallfrozen` freeze coverage in `pg_advisor`. `BUFFERS` is on by default with `EXPLAIN ANALYZE` server-side |
219
+
220
+ If the version probe fails, the server assumes the oldest supported shape rather than emitting SQL a server might reject.
207
221
 
208
222
  ### Runtime
209
223
 
@@ -211,7 +225,7 @@ The published `postgres-mcp` command is a small launcher that prefers the [oam](
211
225
 
212
226
  **If you do not have oam, nothing changes.** The fallback is not a re-exec: npm already started Node to run the launcher, so falling back is a plain `import()` of the server into that same process. It costs a few `existsSync` calls and no subprocess, and behaves identically to running `dist/index.js` under Node directly.
213
227
 
214
- **If you do have oam,** the server runs under it. Verified equivalent on both runtimes: all 21 tools register, queries return identical rows and `dataTypeName` values, and the error paths match. oam supplies every `node:` builtin the driver needs, including `net`, `tls`, `crypto`, and `dns` (SCRAM auth and the extended query protocol both work).
228
+ **If you do have oam,** the server runs under it. Verified equivalent on both runtimes: all 22 tools register, queries return identical rows and `dataTypeName` values, and the error paths match. oam supplies every `node:` builtin the driver needs, including `net`, `tls`, `crypto`, and `dns` (SCRAM auth and the extended query protocol both work).
215
229
 
216
230
  **Startup cost, measured.** windows-arm64, 1.4 MB bundle, `postgres-mcp version` (full module init), every binary warmed first, mean of 12 runs:
217
231
 
@@ -263,6 +277,14 @@ To allow the connection while keeping traffic encrypted, add `POSTGRES_SSL_REJEC
263
277
 
264
278
  This disables certificate chain verification only -- the TCP connection is still TLS-encrypted end-to-end. For production setups where you can install the CA, prefer putting the cert in the Node trust store (`NODE_EXTRA_CA_CERTS`) over disabling verification globally.
265
279
 
280
+ **Shaving a round trip on PG17+.** Postgres 17 added direct TLS negotiation, which skips the plaintext `SSLRequest` handshake before the TLS one. The bundled driver supports it, so append `sslnegotiation=direct` to your `DATABASE_URL`:
281
+
282
+ ```
283
+ postgres://user:pass@host:5432/db?sslmode=require&sslnegotiation=direct
284
+ ```
285
+
286
+ It is opt-in rather than a default because a PG16-or-older server will reject the connection outright, and the saving is one round trip per pooled connection -- worth it on a distant managed database, invisible on a local one.
287
+
266
288
  ## Troubleshooting
267
289
 
268
290
  **`DATABASE_URL is not set`** - Your MCP client is launching the server without the env var. On Windows especially, env vars set in bash / PowerShell profiles are not inherited by MCP servers launched via `cmd`. Put `DATABASE_URL` directly in the `env` block of `.mcp.json`.
@@ -307,7 +329,9 @@ wsl -d Ubuntu -u root bash /mnt/c/path/to/postgres-mcp/scripts/wsl-pg-setup.sh
307
329
  wsl -d Ubuntu -u root bash /mnt/c/path/to/postgres-mcp/scripts/wsl-test-matrix.sh
308
330
  ```
309
331
 
310
- `wsl-pg-setup.sh` installs PG17 and PG18 from the PGDG apt repo on ports 5432 and 5433, sets the `postgres` password to `postgres`, and creates `postgres_mcp_test` in each. `wsl-test-matrix.sh` rsyncs the working tree into `/root/postgres-mcp`, runs `npm ci` once, and runs the integration suite against every cluster found via `pg_lsclusters`.
332
+ `wsl-pg-setup.sh` installs PG15, PG17 and PG18 from the PGDG apt repo (ports are auto-assigned by `pg_createcluster` -- typically 17 on 5432, 18 on 5433, 15 on 5434), sets the `postgres` password to `postgres`, and creates `postgres_mcp_test` in each. `wsl-test-matrix.sh` rsyncs the working tree into `/root/postgres-mcp`, runs `npm ci` once, and runs the integration suite against every cluster found via `pg_lsclusters`.
333
+
334
+ **Running these from Git Bash instead of PowerShell?** Prefix both script invocations with `MSYS_NO_PATHCONV=1`. Git Bash rewrites the `/mnt/c/...` argument before `wsl.exe` sees it, so the script arrives as `C:/Users/<you>/scoop/apps/git/<ver>/mnt/c/...` and bash exits with "No such file or directory" having run nothing. Also avoid piping either script into `tail`/`head` -- the pipeline's exit status is the last command's, so a failing matrix reports success.
311
335
 
312
336
  Tear down when finished: `wsl --unregister Ubuntu`.
313
337
 
@@ -33,19 +33,48 @@
33
33
  * ~980-1290ms), which were wrong in both magnitude and direction. Warm every
34
34
  * candidate first, or stage it out of the build directory.
35
35
  *
36
+ * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
37
+ * `POSTGRES_MCP_SANDBOX=1` runs the server under oam's permission model.
38
+ *
39
+ * The database host is not knowable ahead of time, so the net grant is DERIVED
40
+ * from DATABASE_URL at launch: the one endpoint this server may reach is the one
41
+ * it was configured to reach. Both host and port are pinned, because grants are
42
+ * prefix-matched and a bare host would also admit every other port on it.
43
+ * Filesystem and child-process stay denied.
44
+ *
45
+ * Opt-in, not default. A denied environment variable is ABSENT from process.env
46
+ * rather than throwing, so an under-granted DATABASE_URL would look like "not
47
+ * configured" instead of "denied". The env list is derived from the shipped
48
+ * bundle and includes the pg driver's own reads (PGSSLMODE, PGCONNECT_TIMEOUT
49
+ * and friends) -- a hand-written list misses those.
50
+ *
51
+ * MINIMUM OAM VERSION
52
+ * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
53
+ * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
54
+ * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
55
+ * behaved as `'pipe'`. This server spawns nothing, so the floor is
56
+ * enforced for consistency across @yawlabs/*-mcp rather than because this
57
+ * launcher was exposed.
58
+ * An older oam is not an error: the launcher falls back to Node and says so on
59
+ * stderr. Pinning the floor here is what makes that fallback automatic.
60
+ *
36
61
  * SELECTION
37
62
  * POSTGRES_MCP_RUNTIME=oam require oam; fail loudly if it is missing
38
63
  * POSTGRES_MCP_RUNTIME=node never use oam
39
64
  * POSTGRES_MCP_RUNTIME=auto prefer oam, silently fall back (default)
65
+ * POSTGRES_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
40
66
  * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
41
67
  */
42
68
 
43
- import { spawn } from "node:child_process";
69
+ import { execFileSync, spawn } from "node:child_process";
44
70
  import { existsSync } from "node:fs";
45
71
  import { constants, homedir } from "node:os";
46
72
  import { delimiter, join } from "node:path";
47
73
  import { fileURLToPath } from "node:url";
48
74
 
75
+ /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
76
+ const OAM_MIN = [0, 9, 0];
77
+
49
78
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
50
79
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
51
80
  // in-process fallback must use the file:// URL. spawn(), conversely, needs a
@@ -90,6 +119,80 @@ function findOam() {
90
119
  return null;
91
120
  }
92
121
 
122
+ /**
123
+ * `oam --version` -> [major, minor, patch], or null when it cannot be read.
124
+ * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
125
+ */
126
+ function oamVersion(cmd) {
127
+ try {
128
+ const out = execFileSync(cmd, ["--version"], {
129
+ encoding: "utf-8",
130
+ stdio: ["ignore", "pipe", "ignore"],
131
+ });
132
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
133
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
134
+ } catch {
135
+ // Not executable, wrong arch, or deleted since the stat. Caller degrades.
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /** True when `v` is at least `min`, comparing major/minor/patch in order. */
141
+ function atLeast(v, min) {
142
+ if (!v) return false;
143
+ for (let i = 0; i < min.length; i++) {
144
+ if (v[i] > min[i]) return true;
145
+ if (v[i] < min[i]) return false;
146
+ }
147
+ return true;
148
+ }
149
+
150
+ /**
151
+ * The `--permission` grant list, or [] when the sandbox is not requested.
152
+ *
153
+ * These are oam's PROCESS-level flags: they belong before the `run` subcommand,
154
+ * not after it. `oam run --permission file.js` is rejected outright, which is a
155
+ * good failure but only because it is loud -- ordering here is load-bearing.
156
+ *
157
+ * Net grants prefix-match `host` for fetch and `host:port` for sockets.
158
+ * A denied environment variable is ABSENT from process.env rather than throwing,
159
+ * so the env list below is derived from what the bundle actually reads; trimming
160
+ * it produces silent misbehaviour, not a clear denial.
161
+ */
162
+ function sandboxFlags() {
163
+ if (process.env.POSTGRES_MCP_SANDBOX !== "1") return [];
164
+
165
+ // Derived, not hardcoded: the only endpoint this server may reach is the one
166
+ // it was configured to reach. Grants are prefix-matched against "host:port"
167
+ // for sockets, so host alone would also admit any other port on that host --
168
+ // pin both. A DSN we cannot parse falls back to a bare grant rather than a
169
+ // broken one, because a wrong narrow grant fails at connect time.
170
+ const dsn = process.env.DATABASE_URL ?? null;
171
+ let netFlag = "--allow-net";
172
+ if (dsn) {
173
+ try {
174
+ const u = new URL(dsn);
175
+ if (u.hostname) netFlag = `--allow-net=${u.hostname}:${u.port || 5432}`;
176
+ } catch {
177
+ // Unparseable DATABASE_URL: leave the grant open. The server will fail on
178
+ // its own connection error, which names the real problem.
179
+ }
180
+ }
181
+
182
+ // Every variable the SHIPPED BUNDLE reads, including the pg driver's own
183
+ // lookups. Adding a config env var to src/ without adding it here is a silent
184
+ // regression under the sandbox, not a loud one: oam removes an undeclared var
185
+ // from process.env rather than denying access, so the server reads undefined
186
+ // and quietly takes its default. POSTGRES_APPLICATION_NAME is read by
187
+ // getApplicationName() in src/api.ts; PGAPPNAME is pg's own env fallback for
188
+ // the same setting (connection-parameters.js: val('application_name', config,
189
+ // 'PGAPPNAME')), so omitting it would drop a name set the driver's way.
190
+ const env = ["ALLOW_WRITES","DATABASE_URL","NODE_PG_FORCE_NATIVE","PGAPPNAME","PGCONNECT_TIMEOUT","PGSSLMODE","POSTGRES_APPLICATION_NAME","POSTGRES_CONNECTION_TIMEOUT_MS","POSTGRES_MAX_ROWS","POSTGRES_POOL_MAX","POSTGRES_SSL_REJECT_UNAUTHORIZED","POSTGRES_STATEMENT_TIMEOUT_MS","USER","USERNAME"];
191
+
192
+ const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
193
+ return flags;
194
+ }
195
+
93
196
  /** Run the server in THIS process. The zero-overhead fallback. */
94
197
  async function runInProcess() {
95
198
  await import(SERVER_URL.href);
@@ -116,11 +219,30 @@ if (mode === "node") {
116
219
  process.exit(1);
117
220
  }
118
221
  await runInProcess();
222
+ } else if (!atLeast(oamVersion(oam), OAM_MIN)) {
223
+ // Discovery itself stays stat-only; this is the first subprocess, and it
224
+ // runs only once we have already decided to spawn oam anyway. Measured 26ms
225
+ // median (n=12, windows-arm64), paid once per MCP session.
226
+ const min = OAM_MIN.join(".");
227
+ if (mode === "oam") {
228
+ const { writeSync } = await import("node:fs");
229
+ writeSync(
230
+ 2,
231
+ `postgres-mcp: POSTGRES_MCP_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
232
+ `Run \`oam self-update\`, or use POSTGRES_MCP_RUNTIME=node.\n`,
233
+ );
234
+ process.exit(1);
235
+ }
236
+ // auto: an old oam is a reason to prefer Node, not to fail. Say so, because
237
+ // a silent downgrade is how someone keeps running an oam they meant to
238
+ // update. stderr is safe -- MCP frames travel on stdout.
239
+ process.stderr.write(`postgres-mcp: oam at ${oam} is older than ${min}; using Node instead.\n`);
240
+ await runInProcess();
119
241
  } else {
120
242
  // `--` separates oam's own flags from the script's argv. Everything after
121
243
  // it lands in process.argv for the server, so `postgres-mcp version` and
122
244
  // any host-supplied flags survive the hop unchanged.
123
- const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
245
+ const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
124
246
  // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
125
247
  // stdin/stdout is untouched and the host's stdin-close still reaches the
126
248
  // server's shutdown path.