@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 +211 -0
- package/README.md +34 -10
- package/bin/postgres-mcp.mjs +124 -2
- package/dist/index.js +1600 -363
- package/package.json +4 -4
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,
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
package/bin/postgres-mcp.mjs
CHANGED
|
@@ -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.
|