@yawlabs/postgres-mcp 0.10.0 → 0.11.1
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 +194 -0
- package/README.md +51 -10
- package/bin/postgres-mcp.mjs +48 -5
- package/dist/index.js +1600 -363
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,200 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.1] - 2026-08-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Windows: the launcher no longer hard-kills the server on the first Ctrl-C.** There are no POSIX signals on Windows — `child.kill(sig)` ignores the name and calls `TerminateProcess`, an immediate hard kill (verified: a child with a `SIGTERM` handler never runs it and dies with `code=null`). The launcher forwarded anyway, on the stated assumption that this was a "no-op on Windows", so it aborted the graceful shutdown the console's own Ctrl-C had just started and skipped the server's `process.on("exit")` cleanup. The console already delivers the event to the whole process group, so on Windows the launcher now forwards nothing.
|
|
14
|
+
- **A wedged server no longer leaves the launcher hanging.** Forwarding was gated on `child.killed`, which records only that `kill()` was *called* — never that the child is gone — so every signal after the first was swallowed and there was no escape hatch. Escalation is now armed by a timer on the first signal: one press is enough, and a child still alive after a 2s grace window is killed. Using a timer rather than counting signals also stops the ordinary supervisor sequence (`SIGINT` then `SIGTERM` milliseconds apart) from being misread as impatience.
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
- **The README now carries a "What's new in 0.11.0" section.** The three
|
|
19
|
+
breaking changes lived only in this file, where someone upgrading from
|
|
20
|
+
0.10.x is unlikely to look before their first failing call -- and the
|
|
21
|
+
stats-envelope change fails at the call site rather than at install time,
|
|
22
|
+
since `data` becomes an object where it used to be an array.
|
|
23
|
+
- **Both WSL scripts and the README document the `MSYS_NO_PATHCONV=1` prefix
|
|
24
|
+
they require when invoked from Git Bash on Windows.** Without it, Git Bash
|
|
25
|
+
rewrites the `/mnt/c/...` argument into the Git install prefix before
|
|
26
|
+
`wsl.exe` sees it, and the script exits "No such file or directory" having
|
|
27
|
+
run no tests at all. Piping either script into `tail`/`head` is also called
|
|
28
|
+
out, because the pipeline's exit status is the last command's -- so a red
|
|
29
|
+
matrix reports success.
|
|
30
|
+
|
|
31
|
+
## [0.11.0] - 2026-08-23
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`pg_io_stats`, a new tool for I/O observability.** `pg_stat_io` read /
|
|
36
|
+
write / extend / fsync counts, bytes and times per backend type and context
|
|
37
|
+
(PG16+), plus in-flight async I/O handles from `pg_aios` and the active
|
|
38
|
+
`io_method` (PG18+). Byte accounting differs by server: PG16-17 expose
|
|
39
|
+
`op_bytes` to multiply against operation counts, and PG18 removed it in favour
|
|
40
|
+
of direct `read_bytes` / `write_bytes` / `extend_bytes`. Both branches are
|
|
41
|
+
normalized to one figure, and neither ever names the other's column.
|
|
42
|
+
|
|
43
|
+
- **Server-version gating.** `getServerVersionNum()` in `api.ts` caches
|
|
44
|
+
`server_version_num` per process and every version-dependent column now routes
|
|
45
|
+
through it. A failed probe returns 0, the "assume oldest" sentinel, so an
|
|
46
|
+
unknown server falls through to the conservative query rather than emitting
|
|
47
|
+
SQL referencing a column it may not have. Probe failures are deliberately not
|
|
48
|
+
cached: one transient blip during the first tool call would otherwise pin the
|
|
49
|
+
process to degraded output for its whole lifetime.
|
|
50
|
+
|
|
51
|
+
- **`pg_describe_table` flags generated and identity columns.** `pg_attrdef`
|
|
52
|
+
stores generation expressions alongside plain defaults, so a generated
|
|
53
|
+
column's expression was surfacing as `default_value` with nothing marking it
|
|
54
|
+
-- an agent read that as "optional column, has a default" and wrote an INSERT
|
|
55
|
+
postgres rejects. Identity columns failed the other way: no `pg_attrdef` row
|
|
56
|
+
at all, so they reported `default_value: null, nullable: false` and the agent
|
|
57
|
+
supplied a value into `GENERATED ALWAYS AS IDENTITY`. Columns now carry
|
|
58
|
+
`generated`, `identity`, and `generation_expression`.
|
|
59
|
+
|
|
60
|
+
- **PG18 constraint metadata in `pg_describe_table`.** Constraints carry
|
|
61
|
+
`validated` on every version, plus `enforced` and `has_period` on PG18+. A
|
|
62
|
+
`NOT ENFORCED` foreign key looks present but validates nothing, and a temporal
|
|
63
|
+
`WITHOUT OVERLAPS` primary key previously rendered as an ordinary one. PG18
|
|
64
|
+
also made NOT NULL a real `pg_constraint` row that can be `NOT VALID` -- its
|
|
65
|
+
`attnotnull` docs now read "possibly invalid" -- so `not_null_validated` is
|
|
66
|
+
reported on PG18+, where the question can arise.
|
|
67
|
+
|
|
68
|
+
- **`pg_explain` gained the planner options that actually diagnose a slow
|
|
69
|
+
query**: `buffers`, `settings`, `verbose`, `wal`, `costs`, `timing`, plus
|
|
70
|
+
`generic_plan` (PG16+) for planning a parameterized query with no values, and
|
|
71
|
+
`memory` / `serialize` (PG17+). Options below the server's version are
|
|
72
|
+
rejected up front by name and required major, rather than as a parse error
|
|
73
|
+
from the server.
|
|
74
|
+
|
|
75
|
+
- **`pg_health` reports what a health check needs.** `max_connections` and a
|
|
76
|
+
used-fraction (so a connection count is readable), `wait_event_type` /
|
|
77
|
+
`wait_event` / `backend_type` / transaction age on active queries, and a
|
|
78
|
+
`pg_stat_database` rollup: deadlocks, temp files and bytes, conflicts, cache
|
|
79
|
+
hit ratio, `stats_reset`.
|
|
80
|
+
|
|
81
|
+
- **`pg_advisor` checks wraparound risk**, the classic pageable incident:
|
|
82
|
+
per-database and per-table `age(relfrozenxid)` measured against
|
|
83
|
+
`autovacuum_freeze_max_age`, with a `wraparoundThreshold` parameter mirroring
|
|
84
|
+
the existing sequence-exhaustion one. Freeze coverage via `relallfrozen` on
|
|
85
|
+
PG18+.
|
|
86
|
+
|
|
87
|
+
Multixact wraparound is checked alongside it, on the same rows and with no
|
|
88
|
+
extra round trip: `mxid_age(relminmxid)` against
|
|
89
|
+
`autovacuum_multixact_freeze_max_age`. Multixacts are consumed by row-level
|
|
90
|
+
locking (`SELECT ... FOR SHARE/UPDATE`, foreign-key checks), so a lock-heavy
|
|
91
|
+
workload can exhaust them while `relfrozenxid` still looks perfectly healthy
|
|
92
|
+
-- checking only xids reports such a cluster as clean. A row is flagged when
|
|
93
|
+
EITHER counter crosses the threshold, and `triggered_by` (`xid` /
|
|
94
|
+
`multixact` / `both`) says which, because the remediation differs.
|
|
95
|
+
|
|
96
|
+
- **`pg_health` connection buckets now reconcile against `total`.** Two
|
|
97
|
+
separate ways the old breakdown lied. First, `pg_stat_activity` does not hide
|
|
98
|
+
other users' sessions from an unprivileged role -- it returns the rows with
|
|
99
|
+
`state` NULL -- so `total` was complete while `active` / `idle` counted only
|
|
100
|
+
the caller's own sessions, and an operator could read `active: 0` on a busy
|
|
101
|
+
database. A `state_unavailable` counter now makes that shortfall visible.
|
|
102
|
+
Second, `idle in transaction (aborted)`, `starting`, `fastpath function
|
|
103
|
+
call`, and `disabled` matched no filter and vanished from the breakdown; the
|
|
104
|
+
aborted state is the one that holds locks and blocks vacuum, so it gets its
|
|
105
|
+
own field, and an `other` catch-all absorbs the rest (a `NOT IN` list, so a
|
|
106
|
+
future major's new state cannot silently disappear again).
|
|
107
|
+
|
|
108
|
+
- **`POSTGRES_APPLICATION_NAME`** (default `postgres-mcp`), so agent traffic is
|
|
109
|
+
identifiable in `pg_stat_activity` instead of anonymous -- while `pg_health`
|
|
110
|
+
itself reports `application_name` for every other session. An
|
|
111
|
+
`application_name` in `DATABASE_URL` still wins.
|
|
112
|
+
|
|
113
|
+
### Changed
|
|
114
|
+
|
|
115
|
+
- **BREAKING: `pg_seq_scan_tables` and `pg_unused_indexes` return an envelope,
|
|
116
|
+
not a bare row array.** `data` is now
|
|
117
|
+
`{rows, stats_reset, stats_reset_age_seconds}`; callers read `data.rows`.
|
|
118
|
+
The reason is `pg_unused_indexes` could tell an agent to drop a load-bearing
|
|
119
|
+
index: a scan count is meaningless without knowing when the counters were
|
|
120
|
+
reset, and if that happened an hour ago every index looks unused. Both tools
|
|
121
|
+
also report `last_idx_scan` / `last_seq_scan` on PG16+, where "not scanned
|
|
122
|
+
since March" beats a bare counter.
|
|
123
|
+
|
|
124
|
+
- **BREAKING: `pg_top_queries` returns the same envelope**, for the same
|
|
125
|
+
reason -- it ranks cumulative `total_exec_time` / `calls`. Its clock is NOT
|
|
126
|
+
`pg_stat_database.stats_reset` but `pg_stat_statements_info.stats_reset`, a
|
|
127
|
+
genuinely independent reset point; using the wrong one would have been worse
|
|
128
|
+
than omitting it. The same view supplies `dealloc`, which is the subtler
|
|
129
|
+
trap: it counts how often entries for the least-executed statements were
|
|
130
|
+
evicted for exceeding `pg_stat_statements.max`, so a non-zero value means
|
|
131
|
+
the "top queries" ranking is drawn from an incomplete population. Both
|
|
132
|
+
require extension 1.9 (PostgreSQL 14) and are omitted below it rather than
|
|
133
|
+
returned as nulls that would read as "never reset".
|
|
134
|
+
|
|
135
|
+
- **BREAKING: `pg_explain` with `analyze: true` now emits `BUFFERS`.** PG18
|
|
136
|
+
turns it on by default server-side; on PG15-17 it had to be requested and was
|
|
137
|
+
absent. Plans gain buffer lines, so text plans roughly double in length and
|
|
138
|
+
`POSTGRES_MAX_ROWS` truncation can fire where it previously did not. Pass
|
|
139
|
+
`buffers: false` for the old output.
|
|
140
|
+
|
|
141
|
+
- **BREAKING: the Node floor is now 22.** Node 20 reached end of life; the
|
|
142
|
+
supported lines are 22, 24 and 26. The esbuild target deliberately stays at
|
|
143
|
+
`node20` -- it only controls syntax downleveling, so a lower floor keeps the
|
|
144
|
+
bundle runnable under alternate runtimes.
|
|
145
|
+
|
|
146
|
+
- **Tools register through `registerTool` instead of `server.tool`.** All six
|
|
147
|
+
`server.tool` overloads are deprecated as of SDK 1.30 and are gone in the v2
|
|
148
|
+
packages. `registerTool` is also the only form that can carry `outputSchema`,
|
|
149
|
+
so this is what makes structured tool output reachable later. Tools now
|
|
150
|
+
advertise a top-level `title` as well as `annotations.title`; both are emitted,
|
|
151
|
+
since dropping either regresses hosts that read only one.
|
|
152
|
+
|
|
153
|
+
- **`@modelcontextprotocol/sdk` 1.29.0 -> 1.30.0** (the final v1.x release) and
|
|
154
|
+
**`pg` ^8.14.0 -> ^8.23.0**. The pg bump makes `sslnegotiation=direct`
|
|
155
|
+
available in `DATABASE_URL`, which skips a round trip against PG17+ servers;
|
|
156
|
+
it stays opt-in because a PG16-or-older server rejects the connection.
|
|
157
|
+
|
|
158
|
+
- **Documented where PostgreSQL support actually sits.** PG13 reached end of
|
|
159
|
+
life on 2025-11-13 and PG14 does so on 2026-11-12. The integration matrix
|
|
160
|
+
remains 15 / 17 / 18.
|
|
161
|
+
|
|
162
|
+
### Fixed
|
|
163
|
+
|
|
164
|
+
- **`POSTGRES_APPLICATION_NAME` was missing from the oam sandbox allowlist.**
|
|
165
|
+
Under `POSTGRES_MCP_SANDBOX=1`, oam removes an undeclared variable from
|
|
166
|
+
`process.env` rather than denying access, so the operator's configured name
|
|
167
|
+
would have silently vanished and the server would have reported the default
|
|
168
|
+
-- precisely the silent-misbehaviour failure the launcher's own comment warns
|
|
169
|
+
about. `PGAPPNAME` is now granted too, since the pg driver reads it as its
|
|
170
|
+
own fallback for the same setting. A new test scans the shipped bundle for
|
|
171
|
+
literal `process.env` reads and fails if any config variable is absent from
|
|
172
|
+
the allowlist, so the next one cannot ship silently.
|
|
173
|
+
|
|
174
|
+
- **`pg_io_stats` had no test coverage at all.** `tools.test.ts` builds its own
|
|
175
|
+
`allTools` array separate from `index.ts`, and the new tool was added to one
|
|
176
|
+
and not the other -- exempting it from every structural check, including the
|
|
177
|
+
duplicate-name guard. Both arrays now agree, and the tool has a unit suite
|
|
178
|
+
covering its version branches.
|
|
179
|
+
|
|
180
|
+
- **A version probe suspended across `shutdown()` could republish a stale
|
|
181
|
+
server version.** `shutdown()` clears the cache, but a probe already awaiting
|
|
182
|
+
its query would resolve afterwards and write the OLD server's version into
|
|
183
|
+
the cache the NEW pool uses -- gating catalog queries against the wrong
|
|
184
|
+
server, the exact failure the reset exists to prevent. A generation counter
|
|
185
|
+
now invalidates in-flight probes, matching the guard `resolveTypeNames`
|
|
186
|
+
already had.
|
|
187
|
+
|
|
188
|
+
- **Four `pg_explain` tests asserted the wrong thing whenever `DATABASE_URL`
|
|
189
|
+
was set.** Their helper cleared the env var to force the version probe's
|
|
190
|
+
"unknown" sentinel, but the pool and the version cache are module-scoped and
|
|
191
|
+
survive that, so on any machine with `DATABASE_URL` exported the probe never
|
|
192
|
+
re-ran. The suite passed or failed depending on the developer's environment.
|
|
193
|
+
The helper now calls `shutdown()` on both edges.
|
|
194
|
+
|
|
195
|
+
### Security
|
|
196
|
+
|
|
197
|
+
- **`fast-uri` bumped past the host-confusion advisory (GHSA, high).** It
|
|
198
|
+
reaches the published artifact rather than staying a build-time concern: the
|
|
199
|
+
MCP SDK depends on `ajv`, which depends on `fast-uri`, and esbuild bundles the
|
|
200
|
+
whole graph into `dist/index.js`. "It is only a devDependency" is not the
|
|
201
|
+
right test for this package -- the bundle is what ships, and the dependency
|
|
202
|
+
tree is flattened into it. `npm audit` now reports zero vulnerabilities.
|
|
203
|
+
|
|
10
204
|
## [0.10.0] - 2026-08-08
|
|
11
205
|
|
|
12
206
|
### Added
|
package/README.md
CHANGED
|
@@ -11,6 +11,23 @@ Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
|
11
11
|
|
|
12
12
|
One click adds this to your local Yaw MCP config so it's available in every Yaw Terminal session. Or install manually below.
|
|
13
13
|
|
|
14
|
+
## What's new in 0.11.0
|
|
15
|
+
|
|
16
|
+
PostgreSQL 18 support, a new I/O observability tool, and version-gated catalog queries. Full detail in the [CHANGELOG](CHANGELOG.md).
|
|
17
|
+
|
|
18
|
+
**Three breaking changes if you are upgrading from 0.10.x:**
|
|
19
|
+
|
|
20
|
+
1. **`pg_seq_scan_tables`, `pg_unused_indexes` and `pg_top_queries` return an envelope, not a bare row array.** Read `data.rows` where you used to read `data`. The envelope carries `stats_reset`, because a cumulative scan count means nothing without knowing when the counters were last reset -- if that happened an hour ago, every index looks unused, which is how a load-bearing index gets dropped.
|
|
21
|
+
2. **`pg_explain` with `analyze: true` now emits `BUFFERS`**, matching what PostgreSQL 18 does server-side. Plans get longer; pass `buffers: false` for the old output.
|
|
22
|
+
3. **Node 22 is the floor.** Node 20 reached end of life.
|
|
23
|
+
|
|
24
|
+
**Worth knowing even if you are not upgrading yet:**
|
|
25
|
+
|
|
26
|
+
- `pg_describe_table` now flags generated and identity columns. Previously a generated column's expression surfaced as `default_value` with nothing marking it, so an agent read the column as optional-with-a-default and wrote an `INSERT` that PostgreSQL rejects.
|
|
27
|
+
- New `pg_io_stats` exposes `pg_stat_io` (PG16+) plus in-flight async I/O from `pg_aios` and the active `io_method` (PG18+).
|
|
28
|
+
- `pg_advisor` checks multixact wraparound alongside transaction-ID wraparound. A lock-heavy workload can exhaust multixacts while `relfrozenxid` still looks healthy.
|
|
29
|
+
- Every version-dependent column is gated on `server_version_num`, so older servers get a thinner answer rather than an error.
|
|
30
|
+
|
|
14
31
|
## Backstory
|
|
15
32
|
|
|
16
33
|
Anthropic's reference Postgres MCP server, `@modelcontextprotocol/server-postgres`, was [archived in May 2025](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) and [marked deprecated on npm](https://www.npmjs.com/package/@modelcontextprotocol/server-postgres) in July 2025. Anthropic has not shipped a replacement. Despite the deprecation, the last published version (v0.6.2) is still pulled ~20,000 times per week - a lot of agents are pointed at an unmaintained package.
|
|
@@ -167,22 +184,23 @@ The bigger leverage is multi-tool reasoning. A few real workflows:
|
|
|
167
184
|
| `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
185
|
| `pg_list_schemas` | List non-system schemas. |
|
|
169
186
|
| `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. |
|
|
187
|
+
| `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
188
|
| `pg_list_views` | List views and materialized views in a schema, including their SQL definitions. |
|
|
172
189
|
| `pg_list_functions` | List functions, procedures, and aggregates in a schema with signatures and return types. |
|
|
173
190
|
| `pg_list_extensions` | List installed extensions (pgvector, postgis, pg_stat_statements, etc.) with versions. |
|
|
174
191
|
| `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. |
|
|
192
|
+
| `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. |
|
|
193
|
+
| `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. |
|
|
194
|
+
| `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. |
|
|
195
|
+
| `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+. |
|
|
196
|
+
| `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+. |
|
|
197
|
+
| `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
198
|
| `pg_inspect_locks` | Who is blocking whom right now (blocked PID, blocker PID, lock type, queries). |
|
|
181
199
|
| `pg_list_roles` | Database roles with login/superuser/createdb flags and group memberships. |
|
|
182
200
|
| `pg_table_privileges` | Who has SELECT/INSERT/UPDATE/DELETE/etc. on a table or whole schema. |
|
|
183
201
|
| `pg_table_bloat` | Tables with high dead-tuple ratios - VACUUM candidates. |
|
|
184
202
|
| `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. |
|
|
203
|
+
| `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
204
|
| `pg_kill` | Cancel a running query or terminate a backend connection. Requires `ALLOW_WRITES=1`. |
|
|
187
205
|
|
|
188
206
|
## Configuration
|
|
@@ -198,12 +216,25 @@ All env vars are read from the MCP server's environment:
|
|
|
198
216
|
| `POSTGRES_MAX_ROWS` | `1000` | Cap on rows returned by `pg_query`. |
|
|
199
217
|
| `POSTGRES_POOL_MAX` | `5` | Max pool connections. Set to `1` for single-threaded backends (pglite-socket, PgBouncer transaction mode). |
|
|
200
218
|
| `POSTGRES_SSL_REJECT_UNAUTHORIZED` | unset | Set to `false` to skip TLS cert verification (for managed DBs using private-CA certs). Connection is still encrypted. |
|
|
219
|
+
| `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
220
|
| `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
221
|
| `OAM_BIN` | unset | Explicit path to an `oam` binary, checked before PATH and the default install locations. |
|
|
203
222
|
|
|
204
223
|
### Supported Postgres versions
|
|
205
224
|
|
|
206
|
-
Tested on **PostgreSQL 15, 17 and 18** in the integration matrix.
|
|
225
|
+
Tested on **PostgreSQL 15, 17 and 18** in the integration matrix.
|
|
226
|
+
|
|
227
|
+
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`).
|
|
228
|
+
|
|
229
|
+
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:
|
|
230
|
+
|
|
231
|
+
| Server | What it adds |
|
|
232
|
+
|--------|--------------|
|
|
233
|
+
| PG16+ | `last_idx_scan` / `last_seq_scan` in the stats tools (index/table staleness rather than a bare counter), `pg_explain` `generic_plan` |
|
|
234
|
+
| PG17+ | `pg_explain` `memory` and `serialize` |
|
|
235
|
+
| 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 |
|
|
236
|
+
|
|
237
|
+
If the version probe fails, the server assumes the oldest supported shape rather than emitting SQL a server might reject.
|
|
207
238
|
|
|
208
239
|
### Runtime
|
|
209
240
|
|
|
@@ -211,7 +242,7 @@ The published `postgres-mcp` command is a small launcher that prefers the [oam](
|
|
|
211
242
|
|
|
212
243
|
**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
244
|
|
|
214
|
-
**If you do have oam,** the server runs under it. Verified equivalent on both runtimes: all
|
|
245
|
+
**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
246
|
|
|
216
247
|
**Startup cost, measured.** windows-arm64, 1.4 MB bundle, `postgres-mcp version` (full module init), every binary warmed first, mean of 12 runs:
|
|
217
248
|
|
|
@@ -263,6 +294,14 @@ To allow the connection while keeping traffic encrypted, add `POSTGRES_SSL_REJEC
|
|
|
263
294
|
|
|
264
295
|
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
296
|
|
|
297
|
+
**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`:
|
|
298
|
+
|
|
299
|
+
```
|
|
300
|
+
postgres://user:pass@host:5432/db?sslmode=require&sslnegotiation=direct
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
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.
|
|
304
|
+
|
|
266
305
|
## Troubleshooting
|
|
267
306
|
|
|
268
307
|
**`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 +346,9 @@ wsl -d Ubuntu -u root bash /mnt/c/path/to/postgres-mcp/scripts/wsl-pg-setup.sh
|
|
|
307
346
|
wsl -d Ubuntu -u root bash /mnt/c/path/to/postgres-mcp/scripts/wsl-test-matrix.sh
|
|
308
347
|
```
|
|
309
348
|
|
|
310
|
-
`wsl-pg-setup.sh` installs PG17 and PG18 from the PGDG apt repo on
|
|
349
|
+
`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`.
|
|
350
|
+
|
|
351
|
+
**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
352
|
|
|
312
353
|
Tear down when finished: `wsl --unregister Ubuntu`.
|
|
313
354
|
|
package/bin/postgres-mcp.mjs
CHANGED
|
@@ -179,7 +179,15 @@ function sandboxFlags() {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
|
|
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"];
|
|
183
191
|
|
|
184
192
|
const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
|
|
185
193
|
return flags;
|
|
@@ -260,16 +268,51 @@ if (mode === "node") {
|
|
|
260
268
|
void runInProcess();
|
|
261
269
|
});
|
|
262
270
|
|
|
263
|
-
// Forward termination so the server's own
|
|
264
|
-
//
|
|
265
|
-
//
|
|
271
|
+
// Forward termination so the server's own shutdown path runs in the child
|
|
272
|
+
// rather than the child being orphaned.
|
|
273
|
+
//
|
|
274
|
+
// Registering ANY handler for these suppresses Node's default
|
|
275
|
+
// terminate-on-signal, so the parent's exit has to be arranged explicitly.
|
|
276
|
+
// `child.killed` only records that kill() was CALLED, never that the child
|
|
277
|
+
// is gone, so gating on it swallows every signal after the first and wedges
|
|
278
|
+
// the launcher with no escape hatch.
|
|
279
|
+
//
|
|
280
|
+
// Escalation is driven by a TIMER, not by counting signals. Counting is
|
|
281
|
+
// ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
|
|
282
|
+
// apart, and a terminal Ctrl-C reaches the whole process group, so reading
|
|
283
|
+
// "a second signal" as impatience hard-kills a child that is already
|
|
284
|
+
// shutting down cleanly. A timer makes the count irrelevant -- ONE press is
|
|
285
|
+
// enough, and a wedged child dies on schedule. setTimeout is monotonic, so
|
|
286
|
+
// a wall-clock step cannot mis-gate the window either.
|
|
287
|
+
//
|
|
288
|
+
// POSIX vs Windows, and why we do NOT forward on Windows.
|
|
289
|
+
// On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
|
|
290
|
+
// is what lets the child run its shutdown. On Windows there are no POSIX
|
|
291
|
+
// signals: child.kill IGNORES the name and calls TerminateProcess -- an
|
|
292
|
+
// immediate hard kill (verified: a child with a SIGTERM handler never runs
|
|
293
|
+
// it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
|
|
294
|
+
// graceful shutdown the console's own Ctrl-C just started, skipping the
|
|
295
|
+
// child's process.on("exit") cleanup. The console has already notified the
|
|
296
|
+
// child, so on Windows the timer below is the only kill we issue.
|
|
297
|
+
const ESCALATE_AFTER_MS = 2000;
|
|
298
|
+
let escalation = null;
|
|
266
299
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
267
300
|
process.on(sig, () => {
|
|
268
|
-
|
|
301
|
+
// No try/catch: kill() on an already-exited child returns false, it does
|
|
302
|
+
// not throw. It throws only for a signal the platform does not know,
|
|
303
|
+
// which SIGINT/SIGTERM/SIGKILL never are.
|
|
304
|
+
if (!isWin) child.kill(sig);
|
|
305
|
+
if (escalation) return; // already counting down; further signals are noise
|
|
306
|
+
escalation = setTimeout(() => {
|
|
307
|
+
// Still here after its grace window. Stop waiting on it.
|
|
308
|
+
child.kill("SIGKILL");
|
|
309
|
+
process.exit(128 + (constants.signals[sig] ?? 15));
|
|
310
|
+
}, ESCALATE_AFTER_MS);
|
|
269
311
|
});
|
|
270
312
|
}
|
|
271
313
|
|
|
272
314
|
child.on("exit", (code, signal) => {
|
|
315
|
+
if (escalation) clearTimeout(escalation);
|
|
273
316
|
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
274
317
|
// conventional shell exit status rather than a bare 0.
|
|
275
318
|
if (signal) {
|