@yawlabs/postgres-mcp 0.3.2 → 0.4.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 +306 -0
- package/README.md +67 -15
- package/dist/index.js +356 -33
- package/package.json +12 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.4.0] - 2026-04-25
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- User SQL fetches are now memory-bounded by Postgres, not by Node. The
|
|
14
|
+
prior flow had node-pg materialize the entire result set into memory and
|
|
15
|
+
then sliced down to `POSTGRES_MAX_ROWS` for output -- so a payload like
|
|
16
|
+
`SELECT * FROM big1 CROSS JOIN big2` could OOM the MCP process before
|
|
17
|
+
the 30 s `statement_timeout` fired. User SQL now runs through a
|
|
18
|
+
server-side `DECLARE ... NO SCROLL CURSOR FOR ...` + `FETCH MAX_ROWS+1`
|
|
19
|
+
pattern; only the response-sized batch is ever materialized in Node.
|
|
20
|
+
Non-cursorable statements (DDL, DML without RETURNING, utility commands)
|
|
21
|
+
fall back to a direct execute via `SAVEPOINT` so the outer transaction
|
|
22
|
+
stays alive -- those statements never produce a runaway result set
|
|
23
|
+
anyway.
|
|
24
|
+
- User SQL is now sent with `queryMode: 'extended'`, forcing pg to use the
|
|
25
|
+
extended query protocol regardless of whether `params` is empty. The
|
|
26
|
+
extended protocol restricts each request to a single statement, closing
|
|
27
|
+
the stacked-query injection pattern documented by
|
|
28
|
+
[Datadog Security Labs](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/)
|
|
29
|
+
against the now-archived `@modelcontextprotocol/server-postgres`. Without
|
|
30
|
+
this fix, a payload like `SELECT 1; COMMIT; DROP SCHEMA x CASCADE;` passed
|
|
31
|
+
to `pg_query` would escape the `BEGIN READ ONLY` wrapper and run DDL in
|
|
32
|
+
autocommit. Added an integration regression test that asserts the
|
|
33
|
+
multi-statement request is rejected by Postgres.
|
|
34
|
+
- `pg` minimum bumped from `^8.13.0` to `^8.14.0`. The `queryMode: 'extended'`
|
|
35
|
+
option that backs the stacked-query guard above is silently ignored on pg
|
|
36
|
+
8.13.x -- a fresh `npm install` resolving to that range would have regressed
|
|
37
|
+
the security guard with no visible signal. Today's lockfile pins 8.20.0;
|
|
38
|
+
the range bump prevents future installs from sliding back.
|
|
39
|
+
- `@types/pg` bumped from `^8.11.10` to `^8.20.0` to track pg 8.20.0 runtime.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
- New `pg_advisor` tool: rolled-up DBA lints in one call. Returns three
|
|
43
|
+
categories of findings -- `sequence_exhaustion` (sequences whose
|
|
44
|
+
last_value is past `seqExhaustionThreshold` of max_value, default 50%;
|
|
45
|
+
the classic "BIGINT, eventually" incident class), `tables_without_primary_key`
|
|
46
|
+
(bloat candidates and a sign of design drift), and
|
|
47
|
+
`public_tables_without_rls` (default `public`; configurable via
|
|
48
|
+
`rlsSchemas`). Use as the "what should I be looking at?" starting
|
|
49
|
+
point and drill into `pg_unused_indexes` / `pg_table_bloat` /
|
|
50
|
+
`pg_seq_scan_tables` for the perf side.
|
|
51
|
+
- `pg_explain` accepts a `hypothetical_indexes` parameter -- list of
|
|
52
|
+
`{table, columns, using?}` -- which asks the planner "what would the
|
|
53
|
+
plan be if these indexes existed?". Requires the
|
|
54
|
+
[HypoPG](https://github.com/HypoPG/hypopg) extension
|
|
55
|
+
(`CREATE EXTENSION hypopg;`); the tool returns a friendly hint pointing
|
|
56
|
+
at that command if HypoPG isn't installed. Indexes are session-scoped
|
|
57
|
+
and torn down at the end of the call via `hypopg_reset()`, so they
|
|
58
|
+
never persist across MCP requests and never touch real disk. Closes
|
|
59
|
+
the biggest competitive gap vs. Crystal DBA's Postgres MCP Pro per
|
|
60
|
+
the comp-landscape audit. WSL setup script now installs the
|
|
61
|
+
`postgresql-${V}-hypopg` package opportunistically; the matrix test
|
|
62
|
+
runs against PG17 and PG18 and verifies the planner switches a Seq
|
|
63
|
+
Scan to an Index/Bitmap scan when a hypothetical index is supplied.
|
|
64
|
+
- `pg_describe_table` now returns four new fields:
|
|
65
|
+
- `referenced_by` -- incoming FKs (other tables whose foreign keys point at
|
|
66
|
+
this one). Answers "what depends on this table?" before a destructive
|
|
67
|
+
change. None of the surveyed competing Postgres MCP servers expose this;
|
|
68
|
+
in psql you'd run `\d+` on every candidate table and squint.
|
|
69
|
+
- `constraints` -- CHECK / UNIQUE non-PK / EXCLUDE constraints with each
|
|
70
|
+
constraint's full definition string from `pg_get_constraintdef()`. PK and
|
|
71
|
+
FK still live in their dedicated fields, so no double-listing.
|
|
72
|
+
- `partition_of` -- when the relation is a partition, the parent
|
|
73
|
+
schema / table.
|
|
74
|
+
- `partitions` -- when the relation is a `partitioned_table`, the list of
|
|
75
|
+
children with their `pg_get_expr()` partition bounds.
|
|
76
|
+
- `pg_query` result `fields` now include `dataTypeName` (e.g. `int4`, `text`,
|
|
77
|
+
`jsonb`) alongside `dataTypeID`. Previously LLMs saw only the OID and had
|
|
78
|
+
to map it themselves. Resolution is process-cached against `pg_type`, with
|
|
79
|
+
a single miss-fill query for any OID introduced by `CREATE TYPE` mid-session.
|
|
80
|
+
- npm tarball now ships `LICENSE`, `README.md`, and `CHANGELOG.md` alongside
|
|
81
|
+
the bundle. Previously the `files` allowlist was `["dist/index.js"]` only,
|
|
82
|
+
so `npm pack` produced a tarball with no docs and no license file -- bad
|
|
83
|
+
for downstream consumers and registry surfaces that read README from the
|
|
84
|
+
tarball rather than the repo.
|
|
85
|
+
- README workflow examples now show three multi-tool sequences (unstick a
|
|
86
|
+
hung app via `pg_inspect_locks` -> `pg_kill`, chase a slow page via
|
|
87
|
+
`pg_top_queries` -> `pg_explain` -> `pg_unused_indexes`, oncall triage via
|
|
88
|
+
`pg_health` -> `pg_inspect_locks` -> `pg_replication_status`). The previous
|
|
89
|
+
example list was single-tool only.
|
|
90
|
+
- New `pg_table_bloat` integration test asserts every returned `dead_ratio`
|
|
91
|
+
is a finite number in `[0, 1]`. Locks down the invariant that the recent
|
|
92
|
+
`dead / (live + dead)` formula change was meant to enforce.
|
|
93
|
+
- New `pg_query` integration tests for the `$1`-without-params error path
|
|
94
|
+
and the result-count-equals-`POSTGRES_MAX_ROWS` boundary (must NOT flag
|
|
95
|
+
`truncated: true`).
|
|
96
|
+
- New `pg_explain` integration tests for `analyze: true` on a SELECT in
|
|
97
|
+
read-only mode (works) and on an INSERT in read-only mode (errors with
|
|
98
|
+
the ALLOW_WRITES hint).
|
|
99
|
+
- npm `keywords` expanded with `agent`, `claude`, `claude-code`, `cursor`,
|
|
100
|
+
`llm` so registry / search engine queries for "postgres mcp claude code"
|
|
101
|
+
surface this package.
|
|
102
|
+
- `POSTGRES_CONNECTION_TIMEOUT_MS` env var (default `10000`). Without it, a
|
|
103
|
+
dead host hangs the first connection attempt until the OS times out
|
|
104
|
+
(~2 minutes on most platforms), and the agent waits the whole time before
|
|
105
|
+
surfacing an error.
|
|
106
|
+
- `pg_describe_table` now returns a `kind` field (`table` / `view` /
|
|
107
|
+
`materialized_view` / `partitioned_table` / `foreign_table`). Previously
|
|
108
|
+
the tool silently accepted views and matviews and returned columns with
|
|
109
|
+
empty `primary_key` / `foreign_keys` / `indexes` -- correct, but an LLM
|
|
110
|
+
couldn't tell whether the relation was writable.
|
|
111
|
+
|
|
112
|
+
### Changed
|
|
113
|
+
- README now states the supported Postgres versions: tested on 17 and 18,
|
|
114
|
+
expected to work on 13+.
|
|
115
|
+
|
|
116
|
+
### Changed
|
|
117
|
+
- `pg_top_queries` now returns `calls` and `rows` as JS numbers (cast to
|
|
118
|
+
`float8` in SQL) instead of strings. The previous `::text` cast forced
|
|
119
|
+
consumers to parse `"42"` to use it; the timing fields next to them were
|
|
120
|
+
already numbers, so the response shape was inconsistent. float8 is fine
|
|
121
|
+
-- 2^53 is well above any realistic per-query call/row count.
|
|
122
|
+
- `pg_health` partial-failure shape: failed sub-queries now contribute to a
|
|
123
|
+
top-level `_warnings: string[]` array, and the affected fields stay null
|
|
124
|
+
instead of becoming `{error: "..."}`. Previously a failure of the size
|
|
125
|
+
query made `data.database.size_bytes` resolve to `undefined` with no
|
|
126
|
+
signal -- LLMs couldn't tell "missing" from "errored". This matches the
|
|
127
|
+
`_warnings` convention `pg_describe_table` already uses.
|
|
128
|
+
- `shutdown()` now races `pool.end()` against a 5 s timer. `pool.end()`
|
|
129
|
+
waits for in-flight queries with no upper bound, so a wedged query
|
|
130
|
+
(frozen NFS, network hang) could leave the MCP server appearing stuck on
|
|
131
|
+
exit until the OS reaped the TCP sockets.
|
|
132
|
+
- `pg_table_privileges` description tightened to spell out that omitting
|
|
133
|
+
`table` returns privileges for every table in the schema.
|
|
134
|
+
- README now lists supported PG versions explicitly and groups workflow
|
|
135
|
+
examples by single-tool / multi-tool intent.
|
|
136
|
+
|
|
137
|
+
### Fixed
|
|
138
|
+
- `pg_table_bloat` now uses `dead / (live + dead)` for `dead_ratio` instead of
|
|
139
|
+
`dead / live`. The previous formula reported `0` for tables with `live = 0`
|
|
140
|
+
even when `dead > 0`, hiding the most-bloated tables (an empty-shell table
|
|
141
|
+
full of dead tuples is the textbook VACUUM target). The new formula is
|
|
142
|
+
bounded `[0, 1]`, behaves correctly at edges, and the `WHERE` filter now
|
|
143
|
+
excludes tables with both counters at 0 entirely. The `minDeadRatio`
|
|
144
|
+
parameter description was updated to match.
|
|
145
|
+
- `npm test` now serializes test files with `--test-concurrency=1`. Unit tests
|
|
146
|
+
in `api.test.ts` and `tools/admin.test.ts` both mutate `process.env`
|
|
147
|
+
(`ALLOW_WRITES`, `POSTGRES_MAX_ROWS`, etc.); under Node's default parallel
|
|
148
|
+
test-file scheduling these races could flap on CI.
|
|
149
|
+
- README integration-suite paragraph corrected: the schema is named
|
|
150
|
+
`test_fixture`, not `postgres_mcp_integration`.
|
|
151
|
+
- `scripts/wsl-test-matrix.sh` derives `REPO_SRC` from its own location instead
|
|
152
|
+
of a hardcoded `/mnt/c/Users/jeff/...` path. Anyone other than the original
|
|
153
|
+
author can now run the matrix from their own clone.
|
|
154
|
+
- `release.sh` creates annotated tags (`git tag -a`) and pushes with
|
|
155
|
+
`--follow-tags` instead of `--tags`. The previous `--tags` form pushed every
|
|
156
|
+
local tag, including any unrelated experimental ones lying around;
|
|
157
|
+
`--follow-tags` only pushes the tag(s) reachable from the commits being
|
|
158
|
+
pushed -- but it ignores lightweight tags, so the tag-creation step had to
|
|
159
|
+
switch to annotated to keep working.
|
|
160
|
+
|
|
161
|
+
## [0.3.2] - 2026-04-24
|
|
162
|
+
|
|
163
|
+
### Fixed
|
|
164
|
+
- `pg_explain` with `analyze: true` and `ALLOW_WRITES=1` no longer persists
|
|
165
|
+
writes executed by `EXPLAIN ANALYZE`. Previously the write ran inside a
|
|
166
|
+
`BEGIN; ... COMMIT` transaction, so `pg_explain { analyze: true, sql:
|
|
167
|
+
"INSERT ..." }` would actually insert the row. Now writes run inside a
|
|
168
|
+
`BEGIN; ... ROLLBACK` transaction — the plan (with real row counts and
|
|
169
|
+
timing) comes back but the mutation is rolled back. This matches the user
|
|
170
|
+
expectation when asking for a plan, and the tool description has been
|
|
171
|
+
updated to reflect it.
|
|
172
|
+
- `pg_health` `table_count` now excludes `pg_temp_%` schemas, matching the
|
|
173
|
+
filter in `pg_list_schemas`. The `relkind` filter already masked most
|
|
174
|
+
divergence, but the two queries are now consistent.
|
|
175
|
+
- `pg_seq_scan_tables` ratio column simplified. The previous CASE had a
|
|
176
|
+
branch that only fired when `idx_scan = 0 AND seq_scan = 0` (practically
|
|
177
|
+
unreachable given the table was ordered by `seq_scan DESC`), returning
|
|
178
|
+
`0` and implying a distinction that didn't exist. Now returns `NULL`
|
|
179
|
+
whenever `idx_scan = 0`, which is the meaningful ratio-undefined case.
|
|
180
|
+
|
|
181
|
+
### Added
|
|
182
|
+
- `process.stdin` `end` handler cleans up the pg pool when the MCP client
|
|
183
|
+
disconnects. Previously the server kept running for up to 60 seconds
|
|
184
|
+
(the pool's idle timeout) after the parent closed the pipe.
|
|
185
|
+
- Shared `src/tools/params.ts` for the `paramValue` zod schema, previously
|
|
186
|
+
duplicated verbatim in `query.ts` and `explain.ts`.
|
|
187
|
+
|
|
188
|
+
### Infrastructure
|
|
189
|
+
- Integration test suites now share one `before(setupFixtures)` /
|
|
190
|
+
`after(teardownFixtures)` per file via an outer `describe`, instead of
|
|
191
|
+
running DROP/CREATE per inner `describe`. Each file previously reset
|
|
192
|
+
the fixture schema 3-4 times; now it resets once.
|
|
193
|
+
|
|
194
|
+
## [0.3.1] - 2026-04-22
|
|
195
|
+
|
|
196
|
+
### Fixed
|
|
197
|
+
- `pg_list_roles` with `includeSystem: false` (the default) now actually
|
|
198
|
+
excludes built-in `pg_*` roles. The previous `LIKE 'pg\_%' ESCAPE '\\'`
|
|
199
|
+
filter ended up as SQL `ESCAPE '\\'` (two backslashes), which Postgres
|
|
200
|
+
rejects since `ESCAPE` requires a single character — so the whole filter
|
|
201
|
+
was silently being dropped. Replaced with `starts_with(rolname, 'pg_')`.
|
|
202
|
+
- `pg_describe_table` foreign-key `columns` and `foreign_columns` are now
|
|
203
|
+
proper JSON arrays. They were previously returned as the raw postgres
|
|
204
|
+
text form (e.g. `"{user_id}"`) because `array_agg(name)` returns `name[]`,
|
|
205
|
+
which node-pg doesn't auto-parse. Cast to `text[]` so the driver parses.
|
|
206
|
+
|
|
207
|
+
### Infrastructure (main branch CI hygiene; no user-facing changes)
|
|
208
|
+
- `.gitattributes` forces LF line endings in the working tree on every OS,
|
|
209
|
+
so biome's formatter doesn't reject every file on Windows runners after
|
|
210
|
+
git's auto-CRLF conversion.
|
|
211
|
+
- The integration CI job now starts postgres via `docker run` with
|
|
212
|
+
`-c shared_preload_libraries=pg_stat_statements` instead of the `services:`
|
|
213
|
+
block (which passes options to `docker create`, where `-c` means
|
|
214
|
+
--cpu-shares and collided with the postmaster flag).
|
|
215
|
+
- Cross-platform test discovery via `scripts/run-tests.mjs`. `node --test dist`
|
|
216
|
+
hangs on Windows; `dist/**/*.test.js` globs only expand in bash with
|
|
217
|
+
globstar. The wrapper uses `fs.readdirSync({ recursive: true })` (stdlib)
|
|
218
|
+
and passes explicit paths, plus `--test-concurrency=1` for the integration
|
|
219
|
+
suite so fixture-schema setup doesn't race.
|
|
220
|
+
|
|
221
|
+
## [0.3.0] - 2026-04-22
|
|
222
|
+
|
|
223
|
+
### Added
|
|
224
|
+
- `pg_list_views` — list views and materialized views with SQL definitions.
|
|
225
|
+
- `pg_list_functions` — list functions, procedures, and aggregates with signatures.
|
|
226
|
+
- `pg_list_extensions` — list installed extensions (pgvector, postgis, etc.) with versions.
|
|
227
|
+
- `pg_search_columns` — find columns by name pattern across all user schemas.
|
|
228
|
+
- `pg_top_queries` — top N queries by total/mean execution time from
|
|
229
|
+
`pg_stat_statements`. Detects extension version and picks the right column
|
|
230
|
+
names (v1.8+ uses `total_exec_time`, older uses `total_time`). Returns clear
|
|
231
|
+
setup instructions if the extension is not installed.
|
|
232
|
+
- `pg_list_tables` now accepts `limit` / `offset` for pagination on large schemas.
|
|
233
|
+
- `pg_health` now accepts `activeQueryLimit` (1–100) to override the default of 10.
|
|
234
|
+
- `pg_query` / `pg_explain` `params` now accept arrays and objects (for
|
|
235
|
+
postgres arrays, `ANY`, and json/jsonb columns) in addition to scalars.
|
|
236
|
+
- `POSTGRES_SSL_REJECT_UNAUTHORIZED` env var to disable TLS cert verification for
|
|
237
|
+
managed databases using private-CA certs (Supabase, Neon, RDS). Documented in
|
|
238
|
+
a new "Connecting to managed Postgres" README section.
|
|
239
|
+
- `pg_describe_table` now surfaces partial failures via a `_warnings` array
|
|
240
|
+
instead of silently collapsing FK/index fetch errors into empty lists.
|
|
241
|
+
- Troubleshooting section in README covering common failure modes (env vars,
|
|
242
|
+
auth, timeouts, write-blocked, pool exhaustion, cold-start latency).
|
|
243
|
+
- CHANGELOG.md.
|
|
244
|
+
- Dependabot config for npm + github-actions (weekly, grouped dev deps).
|
|
245
|
+
- Windows CI matrix (ubuntu + windows × Node 18/20/22).
|
|
246
|
+
- Integration test suite (`npm run test:integration`) that exercises every
|
|
247
|
+
tool against a real Postgres instance. Gated on `POSTGRES_MCP_INTEGRATION=1`
|
|
248
|
+
so local `npm test` stays fast with no DB required. CI runs it on Linux via
|
|
249
|
+
a `postgres:16` service container with `pg_stat_statements` preloaded.
|
|
250
|
+
- `pg_inspect_locks` — show current blocking locks (blocked PID, blocker PID,
|
|
251
|
+
relation, lock type, both queries). First tool to reach for when a session
|
|
252
|
+
hangs or the app feels stuck.
|
|
253
|
+
- `pg_list_roles` — database roles with login/superuser/createdb/createrole
|
|
254
|
+
flags and inherited group memberships.
|
|
255
|
+
- `pg_table_privileges` — who has SELECT/INSERT/UPDATE/DELETE/etc. on a table,
|
|
256
|
+
or on all tables in a schema. Useful for pre-migration audits.
|
|
257
|
+
- `pg_seq_scan_tables` — tables with heavy sequential scans relative to index
|
|
258
|
+
scans. Missing-index candidates.
|
|
259
|
+
- `pg_unused_indexes` — non-unique, non-primary indexes with low/zero scan
|
|
260
|
+
counts. Drop candidates (each unused index costs write amplification).
|
|
261
|
+
- `pg_kill` — cancel a running query or terminate a backend by PID. Requires
|
|
262
|
+
`ALLOW_WRITES=1` since it changes session state. Distinguishes `cancel`
|
|
263
|
+
(SIGINT-equivalent, graceful) from `terminate` (SIGTERM, forceful).
|
|
264
|
+
- `pg_table_bloat` — estimate dead tuples and vacuum-candidate tables from
|
|
265
|
+
`pg_stat_user_tables`. No extensions required.
|
|
266
|
+
- `pg_replication_status` — replication slots, connected replicas with lag,
|
|
267
|
+
and current WAL position. Returns empty arrays on a standalone DB rather
|
|
268
|
+
than erroring, so it's safe to call unconditionally.
|
|
269
|
+
- New "What can an agent do with this?" README section with concrete example
|
|
270
|
+
conversations mapped to tool calls.
|
|
271
|
+
|
|
272
|
+
### Changed
|
|
273
|
+
- Loosened identifier validation on `pg_list_tables` and `pg_describe_table`.
|
|
274
|
+
Quoted identifiers (e.g. `"My Table"`) now work. Length capped at 63 bytes
|
|
275
|
+
(the postgres limit) via zod schema; the previous regex-based whitelist
|
|
276
|
+
blocked legitimate identifiers.
|
|
277
|
+
- Pool `idleTimeoutMillis` widened 10s → 60s. MCP sessions routinely have
|
|
278
|
+
minute-long gaps; the short timeout was forcing a reconnect on every tool
|
|
279
|
+
call.
|
|
280
|
+
- `pg_query` / `pg_explain` `sql` inputs now hard-capped at 1 MB.
|
|
281
|
+
|
|
282
|
+
### Fixed
|
|
283
|
+
- `pg_explain` now rejects pre-wrapped SQL (e.g. `"EXPLAIN SELECT 1"`) with a
|
|
284
|
+
clear error instead of producing a `EXPLAIN (...) EXPLAIN SELECT 1` syntax
|
|
285
|
+
error. LLMs frequently make this mistake.
|
|
286
|
+
|
|
287
|
+
## [0.1.1] - 2026-04-21
|
|
288
|
+
|
|
289
|
+
### Changed
|
|
290
|
+
- Release workflow: widened npm registry propagation wait from 1 min to 10 min
|
|
291
|
+
to handle occasional stalls observed on initial publish.
|
|
292
|
+
|
|
293
|
+
## [0.1.0] - 2026-04-21
|
|
294
|
+
|
|
295
|
+
Initial release.
|
|
296
|
+
|
|
297
|
+
### Added
|
|
298
|
+
- `pg_query` — run SQL with read-only-by-default safety. Writes opt in via `ALLOW_WRITES=1`.
|
|
299
|
+
- `pg_list_schemas` — list non-system schemas.
|
|
300
|
+
- `pg_list_tables` — list tables (and optionally views) with estimated row counts.
|
|
301
|
+
- `pg_describe_table` — columns, PK, FKs, indexes.
|
|
302
|
+
- `pg_explain` — `EXPLAIN` / `EXPLAIN ANALYZE` with text or JSON output.
|
|
303
|
+
- `pg_health` — server version, db size, connections, active queries, table count.
|
|
304
|
+
- Single-file bundled distribution (zero runtime deps) for fast `npx` cold starts.
|
|
305
|
+
- Result row truncation at `POSTGRES_MAX_ROWS` (default 1000).
|
|
306
|
+
- Parameterized queries via `params` on `pg_query` and `pg_explain`.
|
package/README.md
CHANGED
|
@@ -2,23 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@yawlabs/postgres-mcp)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/YawLabs/postgres-mcp/actions/workflows/ci.yml) [](https://github.com/YawLabs/postgres-mcp/actions/workflows/release.yml)
|
|
6
5
|
|
|
7
6
|
**Query a PostgreSQL database from Claude Code, Cursor, and any MCP client.** Read-only by default — writes opt in via a single env var — so an agent can't silently drop your tables.
|
|
8
7
|
|
|
9
8
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
10
9
|
|
|
11
|
-
##
|
|
10
|
+
## Backstory
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
That unmaintained package also has a known, [publicly documented stacked-query SQL injection](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/) (Datadog Security Labs) that bypasses its `BEGIN READ ONLY` wrapper with input like `COMMIT; DROP SCHEMA public CASCADE;`. It has never been patched at npm.
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
A handful of community forks have appeared, but each fills a narrow slice:
|
|
14
17
|
|
|
15
|
-
-
|
|
16
|
-
- **
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
- [`@zeddotdev/postgres-context-server`](https://www.npmjs.com/package/@zeddotdev/postgres-context-server) — Zed's fork, primarily a security patch on the original shape.
|
|
19
|
+
- **Postgres MCP Pro** (Crystal DBA) — focused on index tuning and hypothetical-index / buffer-cache diagnostics.
|
|
20
|
+
- **AWS Labs Postgres MCP** — tied to Aurora / RDS Data API + Secrets Manager.
|
|
21
|
+
|
|
22
|
+
None of them position themselves as a general-purpose daily driver you'd hand to Claude Code or Cursor against an arbitrary Postgres: modern introspection, perf helpers, role/privilege awareness, and a write-safety posture out of the box. That's the gap `@yawlabs/postgres-mcp` fills.
|
|
23
|
+
|
|
24
|
+
## Why this one?
|
|
25
|
+
|
|
26
|
+
- **Read-only by default** — user SQL runs in a `BEGIN READ ONLY` transaction, so postgres itself (not string parsing) blocks writes. Opt in with `ALLOW_WRITES=1`.
|
|
27
|
+
- **Extended query protocol for all user SQL** — `pg_query` sends user input with `queryMode: 'extended'`, which restricts each request to a single statement. This closes the [stacked-query injection class](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/) (`COMMIT; DROP SCHEMA x CASCADE;`) that defeated the reference server's `BEGIN READ ONLY` wrapper. Integration test asserts the rejection.
|
|
28
|
+
- **Parameterized queries** — `pg_query` takes a `params` array for `$1`, `$2`, etc. No string-interpolated SQL in our code path.
|
|
29
|
+
- **Written from scratch, actively maintained** — not a fork of the deprecated code. Unit + integration tests (`npm test`, `npm run test:integration`) run against a real Postgres; releases cut via `release.sh`.
|
|
30
|
+
- **Schema introspection built in** — `pg_list_schemas`, `pg_list_tables`, `pg_describe_table` return columns, primary keys, foreign keys, and indexes without the agent having to remember `pg_catalog` joins.
|
|
31
|
+
- **`EXPLAIN` as a first-class tool** — text or JSON format, with optional `ANALYZE`. ANALYZE for non-SELECT statements requires `ALLOW_WRITES=1` and always rolls back, so the plan is real but the write doesn't persist.
|
|
32
|
+
- **Perf diagnostics the deprecated server never had** — `pg_top_queries` (from `pg_stat_statements`), `pg_seq_scan_tables`, `pg_unused_indexes`, `pg_table_bloat`, `pg_inspect_locks`, `pg_replication_status`. Answer "why is this slow?" in one tool call.
|
|
33
|
+
- **Health snapshot** — `pg_health` returns version, db size, connection counts, and the 10 longest-running active queries in one call.
|
|
34
|
+
- **Role and privilege awareness** — `pg_list_roles` and `pg_table_privileges` for the common "who can touch what?" questions.
|
|
35
|
+
- **Instant startup** — ships as a single bundled file with zero runtime dependencies. No multi-minute `node_modules` install on every `npx` cold start.
|
|
20
36
|
- **Result truncation** — large result sets are capped at `POSTGRES_MAX_ROWS` (default 1000) with a `truncated: true` flag, so a stray `SELECT * FROM events` doesn't blow out the model context.
|
|
21
|
-
- **Parameterized queries** — `pg_query` accepts a `params` array for `$1`, `$2`, etc. No string-interpolated SQL.
|
|
22
37
|
|
|
23
38
|
## Quick start
|
|
24
39
|
|
|
@@ -77,29 +92,34 @@ Prefer scoping this to dev/test databases — for production, leave writes off a
|
|
|
77
92
|
|
|
78
93
|
## What can an agent do with this?
|
|
79
94
|
|
|
80
|
-
Once connected, the agent picks tools automatically based on what you ask. A few
|
|
95
|
+
Once connected, the agent picks tools automatically based on what you ask. A few single-tool examples:
|
|
81
96
|
|
|
82
|
-
- **"Describe the users table"** -> `pg_describe_table` -> returns columns, PK, FKs, indexes.
|
|
97
|
+
- **"Describe the users table"** -> `pg_describe_table` -> returns kind, columns, PK, FKs, indexes.
|
|
83
98
|
- **"Which tables have a `user_id` column?"** -> `pg_search_columns` with pattern `user_id` -> one call instead of iterating every table.
|
|
84
99
|
- **"This query is slow, why?"** -> `pg_explain` with `analyze: true` -> returns the plan with actual row counts and timing.
|
|
85
100
|
- **"What's the slowest query we run?"** -> `pg_top_queries` -> returns the top N from `pg_stat_statements` with mean/total/min/max times.
|
|
86
|
-
- **"Why is my app hanging?"** -> `pg_inspect_locks` -> returns blocked PIDs and the queries holding their locks; follow up with `pg_kill` (with `ALLOW_WRITES=1`) to cancel the blocker.
|
|
87
101
|
- **"Do we have any unused indexes?"** -> `pg_unused_indexes` -> returns non-unique, non-primary indexes with zero or low scan counts + their size.
|
|
88
102
|
- **"Is `pgvector` installed?"** -> `pg_list_extensions` -> yes/no with version.
|
|
89
103
|
|
|
104
|
+
The bigger leverage is multi-tool reasoning. A few real workflows:
|
|
105
|
+
|
|
106
|
+
- **Unstick a hung app.** `pg_inspect_locks` returns blocked PID + blocking PID + the offending query, then `pg_kill` (`ALLOW_WRITES=1` required) cancels the blocker. The agent can run both in one turn — it's the fastest path from "the app is frozen" to "back up."
|
|
107
|
+
- **Chase a slow page.** `pg_top_queries` ranks the worst queries, `pg_explain` with `analyze: true` shows the plan for the top hit, `pg_seq_scan_tables` and `pg_unused_indexes` say whether the answer is "add an index here" or "drop a dead one there."
|
|
108
|
+
- **Oncall triage.** `pg_health` checks connectivity + active-query count + database size; `pg_inspect_locks` and `pg_replication_status` confirm whether contention or replication lag is in play before paging the on-call DBA.
|
|
109
|
+
|
|
90
110
|
## Tools
|
|
91
111
|
|
|
92
112
|
| Tool | Description |
|
|
93
113
|
|------|-------------|
|
|
94
|
-
| `pg_query` | Run a SQL query. Read-only by default; writes require `ALLOW_WRITES=1`. Supports parameterized queries via `params`. |
|
|
114
|
+
| `pg_query` | Run a SQL query. Read-only by default; writes require `ALLOW_WRITES=1`. Supports parameterized queries via `params`. Result fields include `dataTypeName` (e.g. `int4`, `jsonb`) alongside `dataTypeID`. |
|
|
95
115
|
| `pg_list_schemas` | List non-system schemas. |
|
|
96
116
|
| `pg_list_tables` | List tables (and optionally views) in a schema with estimated row counts. Paginated via `limit`/`offset`. |
|
|
97
|
-
| `pg_describe_table` |
|
|
117
|
+
| `pg_describe_table` | Kind, columns, PK, outgoing FKs, incoming FKs (`referenced_by`), CHECK / UNIQUE / EXCLUDE constraints, indexes, and partition parent/children for a relation. |
|
|
98
118
|
| `pg_list_views` | List views and materialized views in a schema, including their SQL definitions. |
|
|
99
119
|
| `pg_list_functions` | List functions, procedures, and aggregates in a schema with signatures and return types. |
|
|
100
120
|
| `pg_list_extensions` | List installed extensions (pgvector, postgis, pg_stat_statements, etc.) with versions. |
|
|
101
121
|
| `pg_search_columns` | Find columns by name pattern across all user schemas. Case-insensitive, supports SQL LIKE wildcards. |
|
|
102
|
-
| `pg_explain` | `EXPLAIN` or `EXPLAIN ANALYZE` for a SQL statement. Text or JSON output. |
|
|
122
|
+
| `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. |
|
|
103
123
|
| `pg_health` | Server version, database size, connection count, active queries, table count. |
|
|
104
124
|
| `pg_top_queries` | Top N queries by total/mean execution time. Requires the `pg_stat_statements` extension. |
|
|
105
125
|
| `pg_seq_scan_tables` | Tables with heavy sequential scans — missing-index candidates. |
|
|
@@ -109,6 +129,7 @@ Once connected, the agent picks tools automatically based on what you ask. A few
|
|
|
109
129
|
| `pg_table_privileges` | Who has SELECT/INSERT/UPDATE/DELETE/etc. on a table or whole schema. |
|
|
110
130
|
| `pg_table_bloat` | Tables with high dead-tuple ratios — VACUUM candidates. |
|
|
111
131
|
| `pg_replication_status` | Replication slots, connected replicas, and current WAL position. |
|
|
132
|
+
| `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. |
|
|
112
133
|
| `pg_kill` | Cancel a running query or terminate a backend connection. Requires `ALLOW_WRITES=1`. |
|
|
113
134
|
|
|
114
135
|
## Configuration
|
|
@@ -120,10 +141,15 @@ All env vars are read from the MCP server's environment:
|
|
|
120
141
|
| `DATABASE_URL` | (required) | PostgreSQL connection string. |
|
|
121
142
|
| `ALLOW_WRITES` | unset | Set to `1` or `true` to allow DML/DDL via `pg_query` and `pg_explain` ANALYZE of writes. |
|
|
122
143
|
| `POSTGRES_STATEMENT_TIMEOUT_MS` | `30000` | Per-statement timeout. |
|
|
144
|
+
| `POSTGRES_CONNECTION_TIMEOUT_MS` | `10000` | TCP connect timeout. Without this, a dead host hangs until the OS gives up (~2 minutes). |
|
|
123
145
|
| `POSTGRES_MAX_ROWS` | `1000` | Cap on rows returned by `pg_query`. |
|
|
124
146
|
| `POSTGRES_POOL_MAX` | `5` | Max pool connections. Set to `1` for single-threaded backends (pglite-socket, PgBouncer transaction mode). |
|
|
125
147
|
| `POSTGRES_SSL_REJECT_UNAUTHORIZED` | unset | Set to `false` to skip TLS cert verification (for managed DBs using private-CA certs). Connection is still encrypted. |
|
|
126
148
|
|
|
149
|
+
### Supported Postgres versions
|
|
150
|
+
|
|
151
|
+
Tested on **PostgreSQL 17 and 18** in CI. 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.
|
|
152
|
+
|
|
127
153
|
### Connecting to managed Postgres (Supabase, Neon, RDS, etc.)
|
|
128
154
|
|
|
129
155
|
Most managed databases require TLS but serve certs signed by a private CA that Node's default trust store doesn't recognize. The symptom is one of:
|
|
@@ -159,6 +185,32 @@ This disables certificate chain verification only -- the TCP connection is still
|
|
|
159
185
|
|
|
160
186
|
**First query is slow, subsequent queries are fast** — Expected. The pg driver lazily establishes the first connection; subsequent queries reuse the pool.
|
|
161
187
|
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
Run the full suite (unit + integration) against a real Postgres:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
DATABASE_URL='postgres://user:pass@host:5432/db' POSTGRES_MCP_INTEGRATION=1 npm run test:integration
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The integration suite assumes a disposable database -- it creates and drops a `test_fixture` schema. Don't point it at anything you care about.
|
|
197
|
+
|
|
198
|
+
### Windows: integration tests via WSL2
|
|
199
|
+
|
|
200
|
+
Native Postgres on Windows ARM64 is fragile (UCRT runtime gaps, missing ARM64 builds). The reliable path is a disposable Ubuntu under WSL2 with the integration suite running inside WSL (WSL2's NAT blocks the Windows host from reaching :5432, so don't try to run the tests from PowerShell):
|
|
201
|
+
|
|
202
|
+
```powershell
|
|
203
|
+
wsl --install -d Ubuntu --no-launch
|
|
204
|
+
# reboot, then:
|
|
205
|
+
wsl -d Ubuntu -u root bash -c "apt-get update && apt-get install -y nodejs npm rsync"
|
|
206
|
+
wsl -d Ubuntu -u root bash /mnt/c/path/to/postgres-mcp/scripts/wsl-pg-setup.sh
|
|
207
|
+
wsl -d Ubuntu -u root bash /mnt/c/path/to/postgres-mcp/scripts/wsl-test-matrix.sh
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`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`.
|
|
211
|
+
|
|
212
|
+
Tear down when finished: `wsl --unregister Ubuntu`.
|
|
213
|
+
|
|
162
214
|
## License
|
|
163
215
|
|
|
164
216
|
MIT © 2026 YawLabs
|
package/dist/index.js
CHANGED
|
@@ -35255,6 +35255,12 @@ function getStatementTimeoutMs() {
|
|
|
35255
35255
|
const parsed = Number(raw);
|
|
35256
35256
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 3e4;
|
|
35257
35257
|
}
|
|
35258
|
+
function getConnectionTimeoutMs() {
|
|
35259
|
+
const raw = process.env.POSTGRES_CONNECTION_TIMEOUT_MS;
|
|
35260
|
+
if (!raw) return 1e4;
|
|
35261
|
+
const parsed = Number(raw);
|
|
35262
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1e4;
|
|
35263
|
+
}
|
|
35258
35264
|
function getMaxRows() {
|
|
35259
35265
|
const raw = process.env.POSTGRES_MAX_ROWS;
|
|
35260
35266
|
if (!raw) return 1e3;
|
|
@@ -35284,6 +35290,7 @@ function getPool() {
|
|
|
35284
35290
|
pool = new esm_default.Pool({
|
|
35285
35291
|
connectionString: getDatabaseUrl(),
|
|
35286
35292
|
statement_timeout: getStatementTimeoutMs(),
|
|
35293
|
+
connectionTimeoutMillis: getConnectionTimeoutMs(),
|
|
35287
35294
|
max: getPoolMax(),
|
|
35288
35295
|
// MCP sessions can have minutes-long gaps between tool calls. A short
|
|
35289
35296
|
// idleTimeout forces a reconnect on every tool call. 60s keeps the pool
|
|
@@ -35296,6 +35303,29 @@ function getPool() {
|
|
|
35296
35303
|
});
|
|
35297
35304
|
return pool;
|
|
35298
35305
|
}
|
|
35306
|
+
var typeNameCache = null;
|
|
35307
|
+
async function resolveTypeNames(client, oids) {
|
|
35308
|
+
if (oids.length === 0) return {};
|
|
35309
|
+
if (!typeNameCache) {
|
|
35310
|
+
typeNameCache = /* @__PURE__ */ new Map();
|
|
35311
|
+
const res = await client.query("SELECT oid, typname FROM pg_catalog.pg_type");
|
|
35312
|
+
for (const row of res.rows) typeNameCache.set(row.oid, row.typname);
|
|
35313
|
+
}
|
|
35314
|
+
const missing = oids.filter((o) => !typeNameCache?.has(o));
|
|
35315
|
+
if (missing.length > 0) {
|
|
35316
|
+
const res = await client.query(
|
|
35317
|
+
"SELECT oid, typname FROM pg_catalog.pg_type WHERE oid = ANY($1)",
|
|
35318
|
+
[missing]
|
|
35319
|
+
);
|
|
35320
|
+
for (const row of res.rows) typeNameCache.set(row.oid, row.typname);
|
|
35321
|
+
}
|
|
35322
|
+
const out = {};
|
|
35323
|
+
for (const oid of oids) {
|
|
35324
|
+
const n = typeNameCache.get(oid);
|
|
35325
|
+
if (n !== void 0) out[oid] = n;
|
|
35326
|
+
}
|
|
35327
|
+
return out;
|
|
35328
|
+
}
|
|
35299
35329
|
function formatPgError(err) {
|
|
35300
35330
|
if (!(err instanceof Error)) return String(err);
|
|
35301
35331
|
const errObj = err;
|
|
@@ -35308,25 +35338,55 @@ function formatPgError(err) {
|
|
|
35308
35338
|
}
|
|
35309
35339
|
return parts.join(" ");
|
|
35310
35340
|
}
|
|
35311
|
-
function
|
|
35341
|
+
async function runUserQueryBounded(client, sql, params, maxRows) {
|
|
35342
|
+
await client.query("SAVEPOINT __pgmcp_sp");
|
|
35343
|
+
try {
|
|
35344
|
+
await client.query({
|
|
35345
|
+
text: `DECLARE __pgmcp_cur NO SCROLL CURSOR FOR ${sql}`,
|
|
35346
|
+
values: params,
|
|
35347
|
+
queryMode: "extended"
|
|
35348
|
+
});
|
|
35349
|
+
const fetched = await client.query(`FETCH ${maxRows + 1} FROM __pgmcp_cur`);
|
|
35350
|
+
try {
|
|
35351
|
+
await client.query("CLOSE __pgmcp_cur");
|
|
35352
|
+
} catch {
|
|
35353
|
+
}
|
|
35354
|
+
await client.query("RELEASE SAVEPOINT __pgmcp_sp");
|
|
35355
|
+
return fetched;
|
|
35356
|
+
} catch {
|
|
35357
|
+
await client.query("ROLLBACK TO SAVEPOINT __pgmcp_sp");
|
|
35358
|
+
await client.query("RELEASE SAVEPOINT __pgmcp_sp");
|
|
35359
|
+
return client.query({
|
|
35360
|
+
text: sql,
|
|
35361
|
+
values: params,
|
|
35362
|
+
queryMode: "extended"
|
|
35363
|
+
});
|
|
35364
|
+
}
|
|
35365
|
+
}
|
|
35366
|
+
function toQueryResult(result, maxRows, typeNames = {}) {
|
|
35312
35367
|
const truncated = result.rows.length > maxRows;
|
|
35313
35368
|
const rows = truncated ? result.rows.slice(0, maxRows) : result.rows;
|
|
35314
35369
|
return {
|
|
35315
35370
|
rows,
|
|
35316
35371
|
rowCount: result.rowCount,
|
|
35317
|
-
fields: result.fields.map((f) =>
|
|
35372
|
+
fields: result.fields.map((f) => {
|
|
35373
|
+
const name = typeNames[f.dataTypeID];
|
|
35374
|
+
return name !== void 0 ? { name: f.name, dataTypeID: f.dataTypeID, dataTypeName: name } : { name: f.name, dataTypeID: f.dataTypeID };
|
|
35375
|
+
}),
|
|
35318
35376
|
command: result.command,
|
|
35319
35377
|
...truncated ? { truncated: true } : {}
|
|
35320
35378
|
};
|
|
35321
35379
|
}
|
|
35322
|
-
async function runReadOnly(sql, params = []) {
|
|
35380
|
+
async function runReadOnly(sql, params = [], hooks = {}) {
|
|
35323
35381
|
const client = await getPool().connect();
|
|
35324
35382
|
const maxRows = getMaxRows();
|
|
35325
35383
|
try {
|
|
35326
35384
|
await client.query("BEGIN READ ONLY");
|
|
35327
|
-
|
|
35385
|
+
if (hooks.setup) await hooks.setup(client);
|
|
35386
|
+
const result = await runUserQueryBounded(client, sql, params, maxRows);
|
|
35328
35387
|
await client.query("ROLLBACK");
|
|
35329
|
-
|
|
35388
|
+
const typeNames = await resolveTypeNames(client, [...new Set(result.fields.map((f) => f.dataTypeID))]);
|
|
35389
|
+
return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
|
|
35330
35390
|
} catch (err) {
|
|
35331
35391
|
try {
|
|
35332
35392
|
await client.query("ROLLBACK");
|
|
@@ -35334,6 +35394,12 @@ async function runReadOnly(sql, params = []) {
|
|
|
35334
35394
|
}
|
|
35335
35395
|
return { ok: false, error: formatPgError(err) };
|
|
35336
35396
|
} finally {
|
|
35397
|
+
if (hooks.teardown) {
|
|
35398
|
+
try {
|
|
35399
|
+
await hooks.teardown(client);
|
|
35400
|
+
} catch {
|
|
35401
|
+
}
|
|
35402
|
+
}
|
|
35337
35403
|
client.release();
|
|
35338
35404
|
}
|
|
35339
35405
|
}
|
|
@@ -35348,9 +35414,10 @@ async function runReadWrite(sql, params = []) {
|
|
|
35348
35414
|
const maxRows = getMaxRows();
|
|
35349
35415
|
try {
|
|
35350
35416
|
await client.query("BEGIN");
|
|
35351
|
-
const result = await client
|
|
35417
|
+
const result = await runUserQueryBounded(client, sql, params, maxRows);
|
|
35352
35418
|
await client.query("COMMIT");
|
|
35353
|
-
|
|
35419
|
+
const typeNames = await resolveTypeNames(client, [...new Set(result.fields.map((f) => f.dataTypeID))]);
|
|
35420
|
+
return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
|
|
35354
35421
|
} catch (err) {
|
|
35355
35422
|
try {
|
|
35356
35423
|
await client.query("ROLLBACK");
|
|
@@ -35361,7 +35428,7 @@ async function runReadWrite(sql, params = []) {
|
|
|
35361
35428
|
client.release();
|
|
35362
35429
|
}
|
|
35363
35430
|
}
|
|
35364
|
-
async function runReadWriteRollback(sql, params = []) {
|
|
35431
|
+
async function runReadWriteRollback(sql, params = [], hooks = {}) {
|
|
35365
35432
|
if (!isWritesAllowed()) {
|
|
35366
35433
|
return {
|
|
35367
35434
|
ok: false,
|
|
@@ -35372,9 +35439,11 @@ async function runReadWriteRollback(sql, params = []) {
|
|
|
35372
35439
|
const maxRows = getMaxRows();
|
|
35373
35440
|
try {
|
|
35374
35441
|
await client.query("BEGIN");
|
|
35375
|
-
|
|
35442
|
+
if (hooks.setup) await hooks.setup(client);
|
|
35443
|
+
const result = await runUserQueryBounded(client, sql, params, maxRows);
|
|
35376
35444
|
await client.query("ROLLBACK");
|
|
35377
|
-
|
|
35445
|
+
const typeNames = await resolveTypeNames(client, [...new Set(result.fields.map((f) => f.dataTypeID))]);
|
|
35446
|
+
return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
|
|
35378
35447
|
} catch (err) {
|
|
35379
35448
|
try {
|
|
35380
35449
|
await client.query("ROLLBACK");
|
|
@@ -35382,6 +35451,12 @@ async function runReadWriteRollback(sql, params = []) {
|
|
|
35382
35451
|
}
|
|
35383
35452
|
return { ok: false, error: formatPgError(err) };
|
|
35384
35453
|
} finally {
|
|
35454
|
+
if (hooks.teardown) {
|
|
35455
|
+
try {
|
|
35456
|
+
await hooks.teardown(client);
|
|
35457
|
+
} catch {
|
|
35458
|
+
}
|
|
35459
|
+
}
|
|
35385
35460
|
client.release();
|
|
35386
35461
|
}
|
|
35387
35462
|
}
|
|
@@ -35394,9 +35469,16 @@ async function runInternal(sql, params = []) {
|
|
|
35394
35469
|
}
|
|
35395
35470
|
}
|
|
35396
35471
|
async function shutdown() {
|
|
35397
|
-
|
|
35398
|
-
|
|
35399
|
-
|
|
35472
|
+
typeNameCache = null;
|
|
35473
|
+
if (!pool) return;
|
|
35474
|
+
const ending = pool;
|
|
35475
|
+
pool = null;
|
|
35476
|
+
try {
|
|
35477
|
+
await Promise.race([
|
|
35478
|
+
ending.end(),
|
|
35479
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("pool shutdown timed out after 5s")), 5e3))
|
|
35480
|
+
]);
|
|
35481
|
+
} catch {
|
|
35400
35482
|
}
|
|
35401
35483
|
}
|
|
35402
35484
|
|
|
@@ -35490,7 +35572,7 @@ var adminTools = [
|
|
|
35490
35572
|
},
|
|
35491
35573
|
{
|
|
35492
35574
|
name: "pg_table_privileges",
|
|
35493
|
-
description: "Show which roles have which privileges (SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER) on a table or on
|
|
35575
|
+
description: "Show which roles have which privileges (SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER) on a table or on every table in a schema. If `table` is omitted, the result spans every table in `schema`, ordered by table then grantee. Use this to answer 'who can write to this table?' or to audit schema-wide access before a migration.",
|
|
35494
35576
|
annotations: {
|
|
35495
35577
|
title: "Show table privileges",
|
|
35496
35578
|
readOnlyHint: true,
|
|
@@ -35615,6 +35697,87 @@ var adminTools = [
|
|
|
35615
35697
|
};
|
|
35616
35698
|
}
|
|
35617
35699
|
},
|
|
35700
|
+
{
|
|
35701
|
+
name: "pg_advisor",
|
|
35702
|
+
description: "Rolled-up DBA lint pass. One call returns three categories of findings:\n- sequence_exhaustion: SERIAL / BIGSERIAL / IDENTITY sequences whose `last_value` is above `seqExhaustionThreshold` of `max_value`. The classic incident class.\n- tables_without_primary_key: user tables with no PK. Bloat candidates and a sign of design drift; some replication setups also need PKs.\n- public_tables_without_rls: tables in `public` (or any schema in `rlsSchemas`) with row-level security disabled. Useful as a security baseline check.\nUse this as the 'what should I be looking at?' starting point, then drill into `pg_unused_indexes`, `pg_table_bloat`, `pg_seq_scan_tables` for the perf side.",
|
|
35703
|
+
annotations: {
|
|
35704
|
+
title: "Database advisor (DBA lints)",
|
|
35705
|
+
readOnlyHint: true,
|
|
35706
|
+
destructiveHint: false,
|
|
35707
|
+
idempotentHint: true,
|
|
35708
|
+
openWorldHint: true
|
|
35709
|
+
},
|
|
35710
|
+
inputSchema: external_exports3.object({
|
|
35711
|
+
seqExhaustionThreshold: external_exports3.number().min(0).max(1).default(0.5).describe("Minimum used-fraction (last_value / max_value) to flag a sequence (default 0.5 = 50%)."),
|
|
35712
|
+
rlsSchemas: external_exports3.array(external_exports3.string().min(1).max(63)).default(["public"]).describe("Schemas where RLS-missing should be flagged. Defaults to ['public']."),
|
|
35713
|
+
limit: external_exports3.number().int().min(1).max(500).default(50).describe("Max rows per category (default 50).")
|
|
35714
|
+
}),
|
|
35715
|
+
handler: async (input) => {
|
|
35716
|
+
const { seqExhaustionThreshold, rlsSchemas, limit } = input;
|
|
35717
|
+
const [seqRes, noPkRes, rlsRes] = await Promise.all([
|
|
35718
|
+
runInternal(
|
|
35719
|
+
// pg_sequences was added in PG10. last_value can be NULL on a never-
|
|
35720
|
+
// touched sequence; we filter those out (nothing to report yet).
|
|
35721
|
+
`SELECT
|
|
35722
|
+
schemaname AS schema,
|
|
35723
|
+
sequencename AS sequence,
|
|
35724
|
+
last_value::text AS last_value,
|
|
35725
|
+
max_value::text AS max_value,
|
|
35726
|
+
(last_value::float8 / NULLIF(max_value::float8, 0))::numeric(6, 4)::float8 AS pct_used
|
|
35727
|
+
FROM pg_catalog.pg_sequences
|
|
35728
|
+
WHERE last_value IS NOT NULL
|
|
35729
|
+
AND max_value > 0
|
|
35730
|
+
AND (last_value::float8 / max_value::float8) >= $1
|
|
35731
|
+
ORDER BY pct_used DESC NULLS LAST
|
|
35732
|
+
LIMIT $2`,
|
|
35733
|
+
[seqExhaustionThreshold, limit]
|
|
35734
|
+
),
|
|
35735
|
+
runInternal(
|
|
35736
|
+
`SELECT
|
|
35737
|
+
n.nspname AS schema,
|
|
35738
|
+
c.relname AS "table"
|
|
35739
|
+
FROM pg_catalog.pg_class c
|
|
35740
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
35741
|
+
WHERE c.relkind = 'r'
|
|
35742
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
35743
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
35744
|
+
AND NOT EXISTS (
|
|
35745
|
+
SELECT 1 FROM pg_catalog.pg_index i
|
|
35746
|
+
WHERE i.indrelid = c.oid AND i.indisprimary
|
|
35747
|
+
)
|
|
35748
|
+
ORDER BY n.nspname, c.relname
|
|
35749
|
+
LIMIT $1`,
|
|
35750
|
+
[limit]
|
|
35751
|
+
),
|
|
35752
|
+
runInternal(
|
|
35753
|
+
`SELECT
|
|
35754
|
+
n.nspname AS schema,
|
|
35755
|
+
c.relname AS "table"
|
|
35756
|
+
FROM pg_catalog.pg_class c
|
|
35757
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
35758
|
+
WHERE c.relkind IN ('r', 'p')
|
|
35759
|
+
AND n.nspname = ANY($1)
|
|
35760
|
+
AND NOT c.relrowsecurity
|
|
35761
|
+
ORDER BY n.nspname, c.relname
|
|
35762
|
+
LIMIT $2`,
|
|
35763
|
+
[rlsSchemas, limit]
|
|
35764
|
+
)
|
|
35765
|
+
]);
|
|
35766
|
+
const warnings = [];
|
|
35767
|
+
if (!seqRes.ok) warnings.push(`sequence_exhaustion fetch failed: ${seqRes.error}`);
|
|
35768
|
+
if (!noPkRes.ok) warnings.push(`tables_without_primary_key fetch failed: ${noPkRes.error}`);
|
|
35769
|
+
if (!rlsRes.ok) warnings.push(`public_tables_without_rls fetch failed: ${rlsRes.error}`);
|
|
35770
|
+
return {
|
|
35771
|
+
ok: true,
|
|
35772
|
+
data: {
|
|
35773
|
+
sequence_exhaustion: seqRes.ok ? seqRes.data : [],
|
|
35774
|
+
tables_without_primary_key: noPkRes.ok ? noPkRes.data : [],
|
|
35775
|
+
public_tables_without_rls: rlsRes.ok ? rlsRes.data : [],
|
|
35776
|
+
...warnings.length > 0 ? { _warnings: warnings } : {}
|
|
35777
|
+
}
|
|
35778
|
+
};
|
|
35779
|
+
}
|
|
35780
|
+
},
|
|
35618
35781
|
{
|
|
35619
35782
|
name: "pg_table_bloat",
|
|
35620
35783
|
description: "Estimate table bloat (dead tuples + free space) for tables in a schema. Returns live tuples, dead tuples, dead-tuple ratio, last_vacuum / last_autovacuum timestamps, and total relation size. A high dead_ratio with a stale last_autovacuum is a sign a table needs VACUUM. Cheap \u2014 uses `pg_stat_user_tables`, no extensions required.",
|
|
@@ -35627,7 +35790,7 @@ var adminTools = [
|
|
|
35627
35790
|
},
|
|
35628
35791
|
inputSchema: external_exports3.object({
|
|
35629
35792
|
schema: external_exports3.string().min(1).max(63).optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
35630
|
-
minDeadRatio: external_exports3.number().min(0).max(1).default(0.1).describe("Minimum dead
|
|
35793
|
+
minDeadRatio: external_exports3.number().min(0).max(1).default(0.1).describe("Minimum dead-tuple fraction to include \u2014 dead / (live + dead). Default 0.1 = 10%."),
|
|
35631
35794
|
limit: external_exports3.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
|
|
35632
35795
|
}),
|
|
35633
35796
|
handler: async (input) => {
|
|
@@ -35636,22 +35799,23 @@ var adminTools = [
|
|
|
35636
35799
|
const params = [minDeadRatio, limit];
|
|
35637
35800
|
if (schema) params.push(schema);
|
|
35638
35801
|
return runInternal(
|
|
35802
|
+
// dead_ratio = dead / (live + dead): bounded [0, 1]. A 100%-dead table
|
|
35803
|
+
// (live=0, dead>0) correctly reports 1.0 instead of 0. Tables with both
|
|
35804
|
+
// counters at 0 are filtered out -- nothing to report.
|
|
35639
35805
|
`SELECT
|
|
35640
35806
|
schemaname AS schema,
|
|
35641
35807
|
relname AS "table",
|
|
35642
35808
|
n_live_tup::text AS live_tuples,
|
|
35643
35809
|
n_dead_tup::text AS dead_tuples,
|
|
35644
|
-
|
|
35645
|
-
WHEN n_live_tup = 0 THEN 0
|
|
35646
|
-
ELSE (n_dead_tup::float8 / GREATEST(n_live_tup, 1))::numeric(6, 3)::float8
|
|
35647
|
-
END AS dead_ratio,
|
|
35810
|
+
(n_dead_tup::float8 / (n_live_tup + n_dead_tup))::numeric(6, 3)::float8 AS dead_ratio,
|
|
35648
35811
|
pg_size_pretty(pg_total_relation_size(relid)) AS size_pretty,
|
|
35649
35812
|
pg_total_relation_size(relid)::text AS size_bytes,
|
|
35650
35813
|
last_vacuum::text AS last_vacuum,
|
|
35651
35814
|
last_autovacuum::text AS last_autovacuum,
|
|
35652
35815
|
last_analyze::text AS last_analyze
|
|
35653
35816
|
FROM pg_catalog.pg_stat_user_tables
|
|
35654
|
-
WHERE (
|
|
35817
|
+
WHERE (n_live_tup + n_dead_tup) > 0
|
|
35818
|
+
AND (n_dead_tup::float8 / (n_live_tup + n_dead_tup)) >= $1
|
|
35655
35819
|
${schemaFilter}
|
|
35656
35820
|
ORDER BY n_dead_tup DESC
|
|
35657
35821
|
LIMIT $2`,
|
|
@@ -35667,10 +35831,43 @@ var paramValue = external_exports3.lazy(
|
|
|
35667
35831
|
);
|
|
35668
35832
|
|
|
35669
35833
|
// src/tools/explain.ts
|
|
35834
|
+
var indexAccessMethod = external_exports3.enum(["btree", "hash", "gin", "gist", "brin", "spgist"]);
|
|
35835
|
+
var hypotheticalIndex = external_exports3.object({
|
|
35836
|
+
table: external_exports3.string().min(1).max(127).describe("Target table. Use `schema.table` (e.g. `public.users`) or just `table` for the search_path."),
|
|
35837
|
+
columns: external_exports3.array(external_exports3.string().min(1).max(63)).min(1).describe("Column names in index order. Quoted identifiers are not supported here -- pass plain names."),
|
|
35838
|
+
using: indexAccessMethod.default("btree").describe("Index access method. btree is the right answer for almost every query.")
|
|
35839
|
+
});
|
|
35840
|
+
function quoteIdent(name) {
|
|
35841
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
35842
|
+
}
|
|
35843
|
+
function quoteQualifiedTable(name) {
|
|
35844
|
+
return name.split(".").map((p) => quoteIdent(p)).join(".");
|
|
35845
|
+
}
|
|
35846
|
+
function buildHypopgHooks(indexes) {
|
|
35847
|
+
return {
|
|
35848
|
+
setup: async (client) => {
|
|
35849
|
+
for (const idx of indexes) {
|
|
35850
|
+
const cols = idx.columns.map(quoteIdent).join(", ");
|
|
35851
|
+
const tbl = quoteQualifiedTable(idx.table);
|
|
35852
|
+
const createSql = `CREATE INDEX ON ${tbl} USING ${idx.using} (${cols})`;
|
|
35853
|
+
const r = await client.query(
|
|
35854
|
+
"SELECT (hypopg_create_index($1)).indexname AS indexname",
|
|
35855
|
+
[createSql]
|
|
35856
|
+
);
|
|
35857
|
+
if (!r.rows[0]?.indexname) {
|
|
35858
|
+
throw new Error(`hypopg_create_index returned no index for: ${createSql}`);
|
|
35859
|
+
}
|
|
35860
|
+
}
|
|
35861
|
+
},
|
|
35862
|
+
teardown: async (client) => {
|
|
35863
|
+
await client.query("SELECT hypopg_reset()");
|
|
35864
|
+
}
|
|
35865
|
+
};
|
|
35866
|
+
}
|
|
35670
35867
|
var explainTools = [
|
|
35671
35868
|
{
|
|
35672
35869
|
name: "pg_explain",
|
|
35673
|
-
description: "Get the query plan for a SQL statement. By default, this uses plain EXPLAIN (no execution). Set `analyze: true` to run the query with EXPLAIN ANALYZE \u2014 for non-SELECT statements, ALLOW_WRITES=1 is required (since ANALYZE actually executes the statement). Writes executed during EXPLAIN ANALYZE are always rolled back, so you can inspect a plan for an INSERT/UPDATE/DELETE without persisting the mutation. Format is `text` (default) or `json`. Pass the raw SQL (not an EXPLAIN-prefixed statement).",
|
|
35870
|
+
description: "Get the query plan for a SQL statement. By default, this uses plain EXPLAIN (no execution). Set `analyze: true` to run the query with EXPLAIN ANALYZE \u2014 for non-SELECT statements, ALLOW_WRITES=1 is required (since ANALYZE actually executes the statement). Writes executed during EXPLAIN ANALYZE are always rolled back, so you can inspect a plan for an INSERT/UPDATE/DELETE without persisting the mutation. Format is `text` (default) or `json`. Pass the raw SQL (not an EXPLAIN-prefixed statement). Set `hypothetical_indexes` to a list of `{table, columns, using?}` to ask the planner 'what would the plan be if these indexes existed?' -- requires the HypoPG extension (`CREATE EXTENSION hypopg`). The hypothetical indexes are torn down at the end of the call, never touching real disk.",
|
|
35674
35871
|
annotations: {
|
|
35675
35872
|
title: "Explain query plan",
|
|
35676
35873
|
readOnlyHint: false,
|
|
@@ -35682,10 +35879,13 @@ var explainTools = [
|
|
|
35682
35879
|
sql: external_exports3.string().min(1).max(1e6).describe("The SQL statement to explain. Do NOT prefix with EXPLAIN."),
|
|
35683
35880
|
analyze: external_exports3.boolean().default(false).describe("Run EXPLAIN ANALYZE (actually executes the query)."),
|
|
35684
35881
|
format: external_exports3.enum(["text", "json"]).default("text").describe("Output format."),
|
|
35685
|
-
params: external_exports3.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
|
|
35882
|
+
params: external_exports3.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL."),
|
|
35883
|
+
hypothetical_indexes: external_exports3.array(hypotheticalIndex).optional().describe(
|
|
35884
|
+
"List of indexes the planner should pretend exist for this EXPLAIN. Requires the HypoPG extension. Indexes are session-scoped and reset at the end of the call."
|
|
35885
|
+
)
|
|
35686
35886
|
}),
|
|
35687
35887
|
handler: async (input) => {
|
|
35688
|
-
const { sql, analyze, format, params } = input;
|
|
35888
|
+
const { sql, analyze, format, params, hypothetical_indexes } = input;
|
|
35689
35889
|
if (/^\s*EXPLAIN\b/i.test(sql)) {
|
|
35690
35890
|
return {
|
|
35691
35891
|
ok: false,
|
|
@@ -35696,7 +35896,23 @@ var explainTools = [
|
|
|
35696
35896
|
if (analyze) flags.push("ANALYZE");
|
|
35697
35897
|
if (format === "json") flags.push("FORMAT JSON");
|
|
35698
35898
|
const explainSql = flags.length > 0 ? `EXPLAIN (${flags.join(", ")}) ${sql}` : `EXPLAIN ${sql}`;
|
|
35699
|
-
const
|
|
35899
|
+
const hypoIndexes = hypothetical_indexes ?? [];
|
|
35900
|
+
const hooks = hypoIndexes.length > 0 ? buildHypopgHooks(hypoIndexes) : {};
|
|
35901
|
+
if (hypoIndexes.length > 0) {
|
|
35902
|
+
const check2 = await runInternal(
|
|
35903
|
+
`SELECT EXISTS (
|
|
35904
|
+
SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'hypopg'
|
|
35905
|
+
) AS installed`
|
|
35906
|
+
);
|
|
35907
|
+
if (!check2.ok) return check2;
|
|
35908
|
+
if (!check2.data?.[0]?.installed) {
|
|
35909
|
+
return {
|
|
35910
|
+
ok: false,
|
|
35911
|
+
error: "hypothetical_indexes requires the HypoPG extension. Install with `CREATE EXTENSION hypopg;` (a superuser-equivalent role usually). HypoPG is read-only at the disk level \u2014 it lives entirely in shared memory."
|
|
35912
|
+
};
|
|
35913
|
+
}
|
|
35914
|
+
}
|
|
35915
|
+
const result = analyze && isWritesAllowed() ? await runReadWriteRollback(explainSql, params ?? [], hooks) : await runReadOnly(explainSql, params ?? [], hooks);
|
|
35700
35916
|
if (!result.ok) return result;
|
|
35701
35917
|
const data = result.data;
|
|
35702
35918
|
const rows = data?.rows ?? [];
|
|
@@ -35771,15 +35987,21 @@ var healthTools = [
|
|
|
35771
35987
|
)
|
|
35772
35988
|
]);
|
|
35773
35989
|
if (!versionRes.ok) return versionRes;
|
|
35990
|
+
const warnings = [];
|
|
35991
|
+
if (!sizeRes.ok) warnings.push(`database fetch failed: ${sizeRes.error}`);
|
|
35992
|
+
if (!connsRes.ok) warnings.push(`connections fetch failed: ${connsRes.error}`);
|
|
35993
|
+
if (!activeRes.ok) warnings.push(`active_queries fetch failed: ${activeRes.error}`);
|
|
35994
|
+
if (!tableCountRes.ok) warnings.push(`table_count fetch failed: ${tableCountRes.error}`);
|
|
35774
35995
|
return {
|
|
35775
35996
|
ok: true,
|
|
35776
35997
|
data: {
|
|
35777
35998
|
connected: true,
|
|
35778
35999
|
version: versionRes.data?.[0]?.version,
|
|
35779
|
-
database: sizeRes.ok ? sizeRes.data?.[0] :
|
|
35780
|
-
connections: connsRes.ok ? connsRes.data?.[0] :
|
|
36000
|
+
database: sizeRes.ok ? sizeRes.data?.[0] : null,
|
|
36001
|
+
connections: connsRes.ok ? connsRes.data?.[0] : null,
|
|
35781
36002
|
active_queries: activeRes.ok ? activeRes.data : [],
|
|
35782
|
-
table_count: tableCountRes.ok ? tableCountRes.data?.[0]?.count : null
|
|
36003
|
+
table_count: tableCountRes.ok ? tableCountRes.data?.[0]?.count : null,
|
|
36004
|
+
...warnings.length > 0 ? { _warnings: warnings } : {}
|
|
35783
36005
|
}
|
|
35784
36006
|
};
|
|
35785
36007
|
}
|
|
@@ -35883,7 +36105,7 @@ var schemaTools = [
|
|
|
35883
36105
|
},
|
|
35884
36106
|
{
|
|
35885
36107
|
name: "pg_describe_table",
|
|
35886
|
-
description: "Describe a
|
|
36108
|
+
description: "Describe a relation: kind (table / view / materialized_view / partitioned_table / foreign_table), columns (name, type, nullable, default), primary key, foreign keys (outgoing), `referenced_by` (other tables whose FKs point at this one), `constraints` (CHECK / UNIQUE non-PK / EXCLUDE), indexes, and partition info (`partition_of` parent, `partitions` children). Works on views and materialized views too -- PK/FK/constraint/index lists will simply be empty for a plain view. Use `kind` to disambiguate before assuming you can write to the relation.",
|
|
35887
36109
|
annotations: {
|
|
35888
36110
|
title: "Describe table",
|
|
35889
36111
|
readOnlyHint: true,
|
|
@@ -35897,6 +36119,20 @@ var schemaTools = [
|
|
|
35897
36119
|
}),
|
|
35898
36120
|
handler: async (input) => {
|
|
35899
36121
|
const { schema, table } = input;
|
|
36122
|
+
const kindQuery = `
|
|
36123
|
+
SELECT
|
|
36124
|
+
CASE c.relkind
|
|
36125
|
+
WHEN 'r' THEN 'table'
|
|
36126
|
+
WHEN 'p' THEN 'partitioned_table'
|
|
36127
|
+
WHEN 'v' THEN 'view'
|
|
36128
|
+
WHEN 'm' THEN 'materialized_view'
|
|
36129
|
+
WHEN 'f' THEN 'foreign_table'
|
|
36130
|
+
ELSE c.relkind::text
|
|
36131
|
+
END AS kind
|
|
36132
|
+
FROM pg_catalog.pg_class c
|
|
36133
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
36134
|
+
WHERE n.nspname = $1 AND c.relname = $2
|
|
36135
|
+
`;
|
|
35900
36136
|
const columnsQuery = `
|
|
35901
36137
|
SELECT
|
|
35902
36138
|
a.attname AS name,
|
|
@@ -35961,29 +36197,113 @@ var schemaTools = [
|
|
|
35961
36197
|
AND c.relname = $2
|
|
35962
36198
|
ORDER BY i.relname
|
|
35963
36199
|
`;
|
|
35964
|
-
const
|
|
36200
|
+
const constraintsQuery = `
|
|
36201
|
+
SELECT
|
|
36202
|
+
con.conname AS name,
|
|
36203
|
+
CASE con.contype
|
|
36204
|
+
WHEN 'c' THEN 'check'
|
|
36205
|
+
WHEN 'u' THEN 'unique'
|
|
36206
|
+
WHEN 'x' THEN 'exclude'
|
|
36207
|
+
ELSE con.contype::text
|
|
36208
|
+
END AS type,
|
|
36209
|
+
pg_catalog.pg_get_constraintdef(con.oid, true) AS definition
|
|
36210
|
+
FROM pg_catalog.pg_constraint con
|
|
36211
|
+
JOIN pg_catalog.pg_class c ON c.oid = con.conrelid
|
|
36212
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
36213
|
+
WHERE n.nspname = $1
|
|
36214
|
+
AND c.relname = $2
|
|
36215
|
+
AND con.contype IN ('c', 'u', 'x')
|
|
36216
|
+
ORDER BY con.contype, con.conname
|
|
36217
|
+
`;
|
|
36218
|
+
const referencedByQuery = `
|
|
36219
|
+
SELECT
|
|
36220
|
+
con.conname AS constraint_name,
|
|
36221
|
+
srcn.nspname AS schema,
|
|
36222
|
+
src.relname AS "table",
|
|
36223
|
+
array_agg(srcatt.attname::text ORDER BY u.attposition) AS columns,
|
|
36224
|
+
array_agg(refatt.attname::text ORDER BY u.attposition) AS referenced_columns
|
|
36225
|
+
FROM pg_catalog.pg_constraint con
|
|
36226
|
+
JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
|
|
36227
|
+
JOIN pg_catalog.pg_namespace srcn ON srcn.oid = src.relnamespace
|
|
36228
|
+
JOIN pg_catalog.pg_class ref ON ref.oid = con.confrelid
|
|
36229
|
+
JOIN pg_catalog.pg_namespace refn ON refn.oid = ref.relnamespace
|
|
36230
|
+
JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS u(attnum, attposition) ON TRUE
|
|
36231
|
+
JOIN pg_catalog.pg_attribute srcatt ON srcatt.attrelid = con.conrelid AND srcatt.attnum = u.attnum
|
|
36232
|
+
JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS fu(attnum, attposition) ON fu.attposition = u.attposition
|
|
36233
|
+
JOIN pg_catalog.pg_attribute refatt ON refatt.attrelid = con.confrelid AND refatt.attnum = fu.attnum
|
|
36234
|
+
WHERE refn.nspname = $1
|
|
36235
|
+
AND ref.relname = $2
|
|
36236
|
+
AND con.contype = 'f'
|
|
36237
|
+
GROUP BY con.conname, srcn.nspname, src.relname
|
|
36238
|
+
ORDER BY srcn.nspname, src.relname, con.conname
|
|
36239
|
+
`;
|
|
36240
|
+
const partitionParentQuery = `
|
|
36241
|
+
SELECT
|
|
36242
|
+
parn.nspname AS schema,
|
|
36243
|
+
par.relname AS "table"
|
|
36244
|
+
FROM pg_catalog.pg_inherits i
|
|
36245
|
+
JOIN pg_catalog.pg_class child ON child.oid = i.inhrelid
|
|
36246
|
+
JOIN pg_catalog.pg_namespace childn ON childn.oid = child.relnamespace
|
|
36247
|
+
JOIN pg_catalog.pg_class par ON par.oid = i.inhparent
|
|
36248
|
+
JOIN pg_catalog.pg_namespace parn ON parn.oid = par.relnamespace
|
|
36249
|
+
WHERE childn.nspname = $1
|
|
36250
|
+
AND child.relname = $2
|
|
36251
|
+
AND child.relispartition
|
|
36252
|
+
`;
|
|
36253
|
+
const partitionChildrenQuery = `
|
|
36254
|
+
SELECT
|
|
36255
|
+
childn.nspname AS schema,
|
|
36256
|
+
child.relname AS "table",
|
|
36257
|
+
pg_catalog.pg_get_expr(child.relpartbound, child.oid) AS bound
|
|
36258
|
+
FROM pg_catalog.pg_inherits i
|
|
36259
|
+
JOIN pg_catalog.pg_class par ON par.oid = i.inhparent
|
|
36260
|
+
JOIN pg_catalog.pg_namespace parn ON parn.oid = par.relnamespace
|
|
36261
|
+
JOIN pg_catalog.pg_class child ON child.oid = i.inhrelid
|
|
36262
|
+
JOIN pg_catalog.pg_namespace childn ON childn.oid = child.relnamespace
|
|
36263
|
+
WHERE parn.nspname = $1
|
|
36264
|
+
AND par.relname = $2
|
|
36265
|
+
AND child.relispartition
|
|
36266
|
+
ORDER BY childn.nspname, child.relname
|
|
36267
|
+
`;
|
|
36268
|
+
const [kindRes, cols, pk, fks, idxs, constraints, referencedBy, partitionParent, partitionChildren] = await Promise.all([
|
|
36269
|
+
runInternal(kindQuery, [schema, table]),
|
|
35965
36270
|
runInternal(columnsQuery, [schema, table]),
|
|
35966
36271
|
runInternal(primaryKeyQuery, [schema, table]),
|
|
35967
36272
|
runInternal(foreignKeysQuery, [schema, table]),
|
|
35968
|
-
runInternal(indexesQuery, [schema, table])
|
|
36273
|
+
runInternal(indexesQuery, [schema, table]),
|
|
36274
|
+
runInternal(constraintsQuery, [schema, table]),
|
|
36275
|
+
runInternal(referencedByQuery, [schema, table]),
|
|
36276
|
+
runInternal(partitionParentQuery, [schema, table]),
|
|
36277
|
+
runInternal(partitionChildrenQuery, [schema, table])
|
|
35969
36278
|
]);
|
|
35970
36279
|
if (!cols.ok) return cols;
|
|
35971
36280
|
if (!cols.data || cols.data.length === 0) {
|
|
35972
36281
|
return { ok: false, error: `Table "${schema}"."${table}" not found.` };
|
|
35973
36282
|
}
|
|
36283
|
+
const kind = kindRes.ok ? kindRes.data?.[0]?.kind ?? "table" : "table";
|
|
35974
36284
|
const warnings = [];
|
|
35975
36285
|
if (!pk.ok) warnings.push(`primary_key fetch failed: ${pk.error}`);
|
|
35976
36286
|
if (!fks.ok) warnings.push(`foreign_keys fetch failed: ${fks.error}`);
|
|
35977
36287
|
if (!idxs.ok) warnings.push(`indexes fetch failed: ${idxs.error}`);
|
|
36288
|
+
if (!constraints.ok) warnings.push(`constraints fetch failed: ${constraints.error}`);
|
|
36289
|
+
if (!referencedBy.ok) warnings.push(`referenced_by fetch failed: ${referencedBy.error}`);
|
|
36290
|
+
if (!partitionParent.ok) warnings.push(`partition_of fetch failed: ${partitionParent.error}`);
|
|
36291
|
+
if (!partitionChildren.ok) warnings.push(`partitions fetch failed: ${partitionChildren.error}`);
|
|
36292
|
+
const parentRow = partitionParent.ok ? partitionParent.data?.[0] : void 0;
|
|
35978
36293
|
return {
|
|
35979
36294
|
ok: true,
|
|
35980
36295
|
data: {
|
|
35981
36296
|
schema,
|
|
35982
36297
|
table,
|
|
36298
|
+
kind,
|
|
35983
36299
|
columns: cols.data,
|
|
35984
36300
|
primary_key: pk.ok ? (pk.data ?? []).map((r) => r.column_name) : [],
|
|
35985
36301
|
foreign_keys: fks.ok ? fks.data : [],
|
|
36302
|
+
referenced_by: referencedBy.ok ? referencedBy.data : [],
|
|
36303
|
+
constraints: constraints.ok ? constraints.data : [],
|
|
35986
36304
|
indexes: idxs.ok ? idxs.data : [],
|
|
36305
|
+
...parentRow ? { partition_of: parentRow } : {},
|
|
36306
|
+
...partitionChildren.ok && (partitionChildren.data ?? []).length > 0 ? { partitions: partitionChildren.data } : {},
|
|
35987
36307
|
...warnings.length > 0 ? { _warnings: warnings } : {}
|
|
35988
36308
|
}
|
|
35989
36309
|
};
|
|
@@ -36167,14 +36487,17 @@ var statsTools = [
|
|
|
36167
36487
|
const maxCol = useExecSuffix ? "max_exec_time" : "max_time";
|
|
36168
36488
|
const orderCol = orderBy === "total_time" ? totalCol : orderBy === "mean_time" ? meanCol : "calls";
|
|
36169
36489
|
return runInternal(
|
|
36490
|
+
// calls and rows are bigint counters; cast to float8 so node-pg returns
|
|
36491
|
+
// them as JS numbers (matches the timing fields). Precision is fine --
|
|
36492
|
+
// 2^53 is ~9e15, well above any realistic call/row count.
|
|
36170
36493
|
`SELECT
|
|
36171
36494
|
query,
|
|
36172
|
-
calls::
|
|
36495
|
+
calls::float8 AS calls,
|
|
36173
36496
|
${totalCol}::numeric(18, 2)::float8 AS total_time_ms,
|
|
36174
36497
|
${meanCol}::numeric(18, 2)::float8 AS mean_time_ms,
|
|
36175
36498
|
${minCol}::numeric(18, 2)::float8 AS min_time_ms,
|
|
36176
36499
|
${maxCol}::numeric(18, 2)::float8 AS max_time_ms,
|
|
36177
|
-
rows::
|
|
36500
|
+
rows::float8 AS rows,
|
|
36178
36501
|
CASE
|
|
36179
36502
|
WHEN (shared_blks_hit + shared_blks_read) > 0
|
|
36180
36503
|
THEN (shared_blks_hit::float8 / (shared_blks_hit + shared_blks_read) * 100)::numeric(5, 2)::float8
|
|
@@ -36283,7 +36606,7 @@ function compareVersions(a, b) {
|
|
|
36283
36606
|
}
|
|
36284
36607
|
|
|
36285
36608
|
// src/index.ts
|
|
36286
|
-
var version2 = true ? "0.
|
|
36609
|
+
var version2 = true ? "0.4.0" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
36287
36610
|
var subcommand = process.argv[2];
|
|
36288
36611
|
if (subcommand === "version" || subcommand === "--version") {
|
|
36289
36612
|
console.log(version2);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/postgres-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "PostgreSQL MCP server — query, schema introspection, explain, and health checks for AI assistants",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "YawLabs <contact@yaw.sh>",
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
"mcp",
|
|
16
16
|
"model-context-protocol",
|
|
17
17
|
"ai",
|
|
18
|
+
"agent",
|
|
19
|
+
"claude",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"cursor",
|
|
22
|
+
"llm",
|
|
18
23
|
"sql",
|
|
19
24
|
"schema",
|
|
20
25
|
"explain"
|
|
@@ -25,7 +30,10 @@
|
|
|
25
30
|
"postgres-mcp": "dist/index.js"
|
|
26
31
|
},
|
|
27
32
|
"files": [
|
|
28
|
-
"dist/index.js"
|
|
33
|
+
"dist/index.js",
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"README.md",
|
|
36
|
+
"CHANGELOG.md"
|
|
29
37
|
],
|
|
30
38
|
"scripts": {
|
|
31
39
|
"build": "tsc && node build.mjs",
|
|
@@ -38,14 +46,13 @@
|
|
|
38
46
|
"lint:fix": "biome check --write src/",
|
|
39
47
|
"prepublishOnly": "npm run build"
|
|
40
48
|
},
|
|
41
|
-
"dependencies": {},
|
|
42
49
|
"devDependencies": {
|
|
43
50
|
"@biomejs/biome": "^2.4.12",
|
|
44
51
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
45
52
|
"@types/node": "^25.6.0",
|
|
46
|
-
"@types/pg": "^8.
|
|
53
|
+
"@types/pg": "^8.20.0",
|
|
47
54
|
"esbuild": "^0.28.0",
|
|
48
|
-
"pg": "^8.
|
|
55
|
+
"pg": "^8.14.0",
|
|
49
56
|
"typescript": "^6.0.3",
|
|
50
57
|
"zod": "^4.3.6"
|
|
51
58
|
},
|