@yawlabs/postgres-mcp 0.6.19 → 0.7.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 +132 -0
- package/README.md +6 -0
- package/dist/index.js +141 -23
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,138 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.20] - 2026-06-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `wrapToolHandler` now distinguishes a handler that returned a non-`ApiResponse`
|
|
14
|
+
value (null, a primitive, a raw object) from a handler that returned
|
|
15
|
+
`{ok: false}`. The former surfaces `"Error: tool handler returned a malformed
|
|
16
|
+
result (missing ok)"`; the latter still surfaces `"Error: <error>"`. A
|
|
17
|
+
misbehaving handler no longer collapses into the generic `Unknown error` path.
|
|
18
|
+
`wrapToolHandler` is also now exported from `mcp-wrapper.ts` and covered by
|
|
19
|
+
a dedicated unit test (`mcp-wrapper.test.ts`), so the production mapping is
|
|
20
|
+
exercised without standing up `index.ts` and the stdio transport.
|
|
21
|
+
- `index.ts` resolves `package.json` by walking up from the emitted module path
|
|
22
|
+
rather than via a hard-coded `"../package.json"`. A non-trivial `tsc` emit
|
|
23
|
+
layout (e.g. `dist/src/index.js`) used to crash startup with an unresolvable
|
|
24
|
+
require. Verified against the current `outDir: "dist"` layout; future layout
|
|
25
|
+
changes don't need an `index.ts` patch.
|
|
26
|
+
- Shutdown is now idempotent. SIGINT, SIGTERM, and `stdin.end` can all fire
|
|
27
|
+
near-simultaneously when a client closes the connection while the shell also
|
|
28
|
+
sends a signal. An `exiting` flag short-circuits the second and third so
|
|
29
|
+
`shutdown()` and `process.exit()` don't race.
|
|
30
|
+
- `index.ts` no longer fakes a `version` string when `__VERSION__` is unset
|
|
31
|
+
(which only happens on a plain `tsc` build, not the esbuild bundled output).
|
|
32
|
+
The new `readPackageVersion()` throws a clear error if it can't find
|
|
33
|
+
`package.json` rather than silently emitting a malformed banner.
|
|
34
|
+
|
|
35
|
+
### Security
|
|
36
|
+
- `paramValue` (used by `pg_query`, `pg_readonly`, `pg_explain` for positional
|
|
37
|
+
parameters) now uses `.finite()` on the number member, so `NaN`, `Infinity`,
|
|
38
|
+
and `-Infinity` are rejected at the MCP boundary. Without `.finite()`, pg
|
|
39
|
+
serializes the JS number to the literal string `'NaN'` / `'Infinity'`, which
|
|
40
|
+
the server happily accepts on a text column and rejects opaquely on a numeric
|
|
41
|
+
one. Now the request is rejected before it ever hits the database.
|
|
42
|
+
- `identSchema` dropped its redundant `.max(63)` so the byte-length `.refine`
|
|
43
|
+
is the sole length guard. A 64-ASCII-char string now surfaces the tailored
|
|
44
|
+
"exceeds PostgreSQL's 63-byte NAMEDATALEN limit" message instead of Zod's
|
|
45
|
+
generic "at most 63 character(s)" -- agents that read the message act on it
|
|
46
|
+
correctly.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
- `pg_explain` rejects pre-wrapped `EXPLAIN ...` SQL with a clear hint. An LLM
|
|
50
|
+
that calls the tool with `sql: "EXPLAIN ANALYZE SELECT ..."` no longer
|
|
51
|
+
becomes the double-`EXPLAIN` syntax error from the server; the handler
|
|
52
|
+
short-circuits with "the `sql` parameter should be the query to explain, not
|
|
53
|
+
an EXPLAIN statement." Also re-applies Zod defaults (`analyze`, `format`,
|
|
54
|
+
`using`) on the direct-call path so unit tests that bypass Zod still hit
|
|
55
|
+
the documented behavior.
|
|
56
|
+
- `pg_explain` hypothetical_indexes pre-flight rejects over-qualified
|
|
57
|
+
`schema.table.extra` table names, not just pre-quoted / over-63-byte ones.
|
|
58
|
+
An `a.b.c` form previously rendered as `"a"."b"."c"` and surfaced a
|
|
59
|
+
confusing planner error; now it's rejected with a clear message.
|
|
60
|
+
- `pg_describe_table` populates `_warnings` even when the `kind` sub-query
|
|
61
|
+
returned zero rows (not just when it errored). The `?? "table"` default
|
|
62
|
+
would otherwise silently mislabel a relation that was dropped between the
|
|
63
|
+
`columns` and `kind` fetches.
|
|
64
|
+
- `pg_health` warns when the version sub-query returned zero rows (rather
|
|
65
|
+
than silently emitting `version: undefined`). Symmetric with the other
|
|
66
|
+
four sub-queries.
|
|
67
|
+
- Per-tool `inputSchema parses a type-correct sample input` test in
|
|
68
|
+
`tools.test.ts`. Catches drift between the schema's declared shape and
|
|
69
|
+
the keys the handler destructures at runtime. Uses public Zod 4
|
|
70
|
+
constructors (`instanceof z.ZodString`, etc.) -- the `_def` introspection
|
|
71
|
+
path is brittle across Zod majors.
|
|
72
|
+
|
|
73
|
+
### Docs
|
|
74
|
+
- `pg_query` description now leads with "Postgres itself is the primary
|
|
75
|
+
safety gate" and presents `ALLOW_WRITES=1` as a secondary belt-and-braces.
|
|
76
|
+
Pre-0.6.20 the order implied `ALLOW_WRITES` was the primary control, which
|
|
77
|
+
is the opposite of the recommended posture (a least-privileged role in
|
|
78
|
+
`DATABASE_URL`).
|
|
79
|
+
- `getPool()` env-var snapshot comment now leads with which two values
|
|
80
|
+
(`getMaxRows`, `isWritesAllowed`) are intentionally re-read per request,
|
|
81
|
+
with rationale, instead of burying the re-read list at the end of a
|
|
82
|
+
paragraph about the snapshot.
|
|
83
|
+
|
|
84
|
+
### Infrastructure
|
|
85
|
+
- `release.sh` accepts `REQUIRE_MATRIX=1`. When set, the existing "WSL Ubuntu
|
|
86
|
+
not detected" warning becomes a hard `fail`; default behavior is unchanged
|
|
87
|
+
(warn-only so contributors without WSL can still tag). The matrix remains
|
|
88
|
+
a local-only pre-tag gate; this is just an opt-in fail-fast.
|
|
89
|
+
- `package.json` `package-lock.json` and `server.json` all bumped to 0.6.20.
|
|
90
|
+
|
|
91
|
+
## [0.6.19] - 2026-06-02
|
|
92
|
+
|
|
93
|
+
Release-flow hardening; no library behavior changes shipped in this version.
|
|
94
|
+
|
|
95
|
+
### Fixed
|
|
96
|
+
- `release.sh` refuses to push when origin already has `v<version>` pointing
|
|
97
|
+
at a different commit (rewound tag elsewhere, parallel release race) --
|
|
98
|
+
previously `git push --follow-tags` silently skipped the stale tag and the
|
|
99
|
+
GitHub release linked the wrong commit. Compares tag-object SHAs so resume
|
|
100
|
+
runs don't false-abort.
|
|
101
|
+
- README "Add to Yaw MCP" badge points at the https forwarder so it renders
|
|
102
|
+
as a link on github.com (raw `yaw://` hrefs are stripped).
|
|
103
|
+
|
|
104
|
+
### Added
|
|
105
|
+
- `SKIP_LINT=1` escape hatch in `release.sh` for hosts where the npm
|
|
106
|
+
run-script wrapper segfaults on exit-cleanup (MINGW64-ARM64).
|
|
107
|
+
- `wrapToolHandler` extracted from `index.ts` for testability, with unit
|
|
108
|
+
coverage of the MCP result wrapper and the connect-failure path (expanded
|
|
109
|
+
further in 0.6.20).
|
|
110
|
+
|
|
111
|
+
## [0.6.18] - 2026-05-28
|
|
112
|
+
|
|
113
|
+
### Changed
|
|
114
|
+
- Release publishing consolidated into `release.sh`: the MCP Registry publish
|
|
115
|
+
moved into the script and `release.yml` (plus the non-release CI workflows)
|
|
116
|
+
was dropped. The script hands off to CI when a CI publish path exists and
|
|
117
|
+
publishes from the workstation otherwise.
|
|
118
|
+
|
|
119
|
+
### Fixed
|
|
120
|
+
- `release.sh` syncs `server.json` unconditionally, not only inside the bump
|
|
121
|
+
branch, so a resume run no longer asks mcp-publisher to re-publish the
|
|
122
|
+
previous version (400 duplicate-version).
|
|
123
|
+
- `release.sh` falls back to the gh CLI session token when
|
|
124
|
+
`MCP_REGISTRY_TOKEN` is unset.
|
|
125
|
+
- The release confirmation prompt is tty-gated so non-interactive runs don't
|
|
126
|
+
hang on `read`.
|
|
127
|
+
|
|
128
|
+
### Docs
|
|
129
|
+
- README install badge swapped to the "Add to Yaw MCP" deep link; `npx`
|
|
130
|
+
spawn examples pinned to `@latest` for auto-update.
|
|
131
|
+
|
|
132
|
+
## [0.6.17] - 2026-05-19
|
|
133
|
+
|
|
134
|
+
### Added
|
|
135
|
+
- `release.sh` accepts an optional pre-release commit message as a second
|
|
136
|
+
argument: runs the pre-commit checklist, commits tracked changes, then
|
|
137
|
+
proceeds with the release.
|
|
138
|
+
- Post-publish smoke script (`scripts/post-publish-smoke.sh`) wired into the
|
|
139
|
+
release flow -- exercises the published tarball via a real `npx` install
|
|
140
|
+
instead of trusting `npm view` registry metadata.
|
|
141
|
+
|
|
10
142
|
## [0.6.16] - 2026-05-18
|
|
11
143
|
|
|
12
144
|
### Tests
|
package/README.md
CHANGED
|
@@ -246,6 +246,12 @@ DATABASE_URL='postgres://user:pass@host:5432/db' POSTGRES_MCP_INTEGRATION=1 npm
|
|
|
246
246
|
|
|
247
247
|
The integration suite assumes a disposable database -- it creates and drops a `test_fixture` schema. Don't point it at anything you care about.
|
|
248
248
|
|
|
249
|
+
To also run the destructive tests (REVOKE / restricted-role path), add `POSTGRES_MCP_DESTRUCTIVE_TESTS=1`. Only safe on a disposable cluster:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
DATABASE_URL='postgres://user:pass@host:5432/db' POSTGRES_MCP_INTEGRATION=1 POSTGRES_MCP_DESTRUCTIVE_TESTS=1 npm run test:integration
|
|
253
|
+
```
|
|
254
|
+
|
|
249
255
|
### Windows: integration tests via WSL2
|
|
250
256
|
|
|
251
257
|
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):
|
package/dist/index.js
CHANGED
|
@@ -36354,6 +36354,17 @@ function wrapToolHandler(handler) {
|
|
|
36354
36354
|
return async (input) => {
|
|
36355
36355
|
try {
|
|
36356
36356
|
const result = await handler(input);
|
|
36357
|
+
if (result === null || typeof result !== "object" || !("ok" in result)) {
|
|
36358
|
+
return {
|
|
36359
|
+
content: [
|
|
36360
|
+
{
|
|
36361
|
+
type: "text",
|
|
36362
|
+
text: "Error: tool handler returned a malformed result (missing ok)"
|
|
36363
|
+
}
|
|
36364
|
+
],
|
|
36365
|
+
isError: true
|
|
36366
|
+
};
|
|
36367
|
+
}
|
|
36357
36368
|
const response = result;
|
|
36358
36369
|
if (!response.ok) {
|
|
36359
36370
|
return {
|
|
@@ -36366,7 +36377,11 @@ function wrapToolHandler(handler) {
|
|
|
36366
36377
|
isError: true
|
|
36367
36378
|
};
|
|
36368
36379
|
}
|
|
36369
|
-
const text = JSON.stringify(
|
|
36380
|
+
const text = JSON.stringify(
|
|
36381
|
+
response.data ?? { success: true },
|
|
36382
|
+
(_k, v) => typeof v === "bigint" ? v.toString() : v,
|
|
36383
|
+
2
|
|
36384
|
+
);
|
|
36370
36385
|
return {
|
|
36371
36386
|
content: [{ type: "text", text }]
|
|
36372
36387
|
};
|
|
@@ -36382,7 +36397,14 @@ function wrapToolHandler(handler) {
|
|
|
36382
36397
|
|
|
36383
36398
|
// src/tools/params.ts
|
|
36384
36399
|
var paramValue = external_exports.lazy(
|
|
36385
|
-
() => external_exports.union([
|
|
36400
|
+
() => external_exports.union([
|
|
36401
|
+
external_exports.string(),
|
|
36402
|
+
external_exports.number().finite(),
|
|
36403
|
+
external_exports.boolean(),
|
|
36404
|
+
external_exports.null(),
|
|
36405
|
+
external_exports.array(paramValue),
|
|
36406
|
+
external_exports.record(external_exports.string(), paramValue)
|
|
36407
|
+
])
|
|
36386
36408
|
);
|
|
36387
36409
|
var MAX_PARAM_DEPTH = 32;
|
|
36388
36410
|
function isWithinDepth(value, maxDepth) {
|
|
@@ -36403,7 +36425,7 @@ function isWithinDepth(value, maxDepth) {
|
|
|
36403
36425
|
var paramsArray = external_exports.array(paramValue).refine((arr) => arr.every((v) => isWithinDepth(v, MAX_PARAM_DEPTH)), {
|
|
36404
36426
|
message: `Parameter value exceeds maximum nesting depth of ${MAX_PARAM_DEPTH}`
|
|
36405
36427
|
});
|
|
36406
|
-
var identSchema = external_exports.string().min(1).
|
|
36428
|
+
var identSchema = external_exports.string().min(1).refine((v) => Buffer.byteLength(v, "utf8") <= 63, {
|
|
36407
36429
|
message: "Identifier exceeds PostgreSQL's 63-byte NAMEDATALEN limit (multi-byte characters count as multiple bytes)."
|
|
36408
36430
|
});
|
|
36409
36431
|
|
|
@@ -36411,7 +36433,7 @@ var identSchema = external_exports.string().min(1).max(63).refine((v) => Buffer.
|
|
|
36411
36433
|
var adminTools = [
|
|
36412
36434
|
{
|
|
36413
36435
|
name: "pg_inspect_locks",
|
|
36414
|
-
description: "Show current lock contention: which sessions are blocked and who is blocking them. Returns blocked PID, blocking PID, lock types, relation being contested, and the queries involved. Use this first when a tool call hangs or the app feels stuck - it's the fastest way to identify a long-held transaction holding a lock. Row shape: one row per (blocked_pid, blocking_pid) pair. A session waiting on multiple blockers appears on multiple rows -- group/deduplicate by `blocked_pid` if you want a per-blocked-session count.",
|
|
36436
|
+
description: "Show current lock contention: which sessions are blocked and who is blocking them. Returns blocked PID, blocking PID, lock types, relation being contested, and the queries involved. Use this first when a tool call hangs or the app feels stuck - it's the fastest way to identify a long-held transaction holding a lock. Row shape: one row per (blocked_pid, blocking_pid) pair. A session waiting on multiple blockers appears on multiple rows -- group/deduplicate by `blocked_pid` if you want a per-blocked-session count. Caveat on `relation`: for non-relation waits (transactionid/virtualxid, where the wait is on the blocker's xid rather than a table) `relation` is a best-effort hint -- an alphabetical guess among the blocker's held write-intent locks -- not authoritative. Use the blocked/blocking query text to disambiguate which table is actually contested.",
|
|
36415
36437
|
annotations: {
|
|
36416
36438
|
title: "Inspect blocking locks",
|
|
36417
36439
|
readOnlyHint: true,
|
|
@@ -36547,7 +36569,7 @@ var adminTools = [
|
|
|
36547
36569
|
table_name AS "table",
|
|
36548
36570
|
grantee,
|
|
36549
36571
|
privilege_type,
|
|
36550
|
-
is_grantable
|
|
36572
|
+
(is_grantable = 'YES') AS is_grantable
|
|
36551
36573
|
FROM information_schema.table_privileges
|
|
36552
36574
|
WHERE table_schema = $1
|
|
36553
36575
|
${tableFilter}
|
|
@@ -36669,7 +36691,7 @@ var adminTools = [
|
|
|
36669
36691
|
},
|
|
36670
36692
|
{
|
|
36671
36693
|
name: "pg_advisor",
|
|
36672
|
-
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.",
|
|
36694
|
+
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 (plain and partitioned) with no PK defined. Bloat candidates and a sign of design drift; some replication setups also need PKs. Foreign tables are excluded -- PostgreSQL forbids declaring PKs on foreign tables.\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.",
|
|
36673
36695
|
annotations: {
|
|
36674
36696
|
title: "Database advisor (DBA lints)",
|
|
36675
36697
|
readOnlyHint: true,
|
|
@@ -36692,6 +36714,11 @@ var adminTools = [
|
|
|
36692
36714
|
// Divide in `numeric`, not `float8`: BIGINT sequences past 2^53 lose
|
|
36693
36715
|
// precision in float8, and the danger zone (>= threshold) is exactly
|
|
36694
36716
|
// where the reported pct_used must stay accurate.
|
|
36717
|
+
// Filter vs display precision differ on purpose: the WHERE clause
|
|
36718
|
+
// tests the full-precision ratio while the SELECT rounds pct_used
|
|
36719
|
+
// to numeric(6,4) for display. So a displayed 0.5000 may correspond
|
|
36720
|
+
// to a true ratio slightly above the threshold -- the filter is
|
|
36721
|
+
// correct, the display is rounded.
|
|
36695
36722
|
`SELECT
|
|
36696
36723
|
schemaname AS schema,
|
|
36697
36724
|
sequencename AS sequence,
|
|
@@ -36717,6 +36744,10 @@ var adminTools = [
|
|
|
36717
36744
|
// Partition children (relkind='r') inherit the parent's PK as an
|
|
36718
36745
|
// indisprimary index on the child, so the NOT EXISTS clause keeps
|
|
36719
36746
|
// already filtering them out.
|
|
36747
|
+
//
|
|
36748
|
+
// Foreign tables (relkind='f') are excluded: PostgreSQL forbids
|
|
36749
|
+
// PRIMARY KEY (and UNIQUE) constraints on foreign tables entirely,
|
|
36750
|
+
// so they would always appear here with no possible remediation.
|
|
36720
36751
|
`SELECT
|
|
36721
36752
|
n.nspname AS schema,
|
|
36722
36753
|
c.relname AS "table"
|
|
@@ -36765,7 +36796,7 @@ var adminTools = [
|
|
|
36765
36796
|
},
|
|
36766
36797
|
{
|
|
36767
36798
|
name: "pg_table_bloat",
|
|
36768
|
-
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
|
|
36799
|
+
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.\n\nThree methods are available via the `method` parameter:\n- `estimate` (default): reads pg_stat_user_tables -- fast, no extensions, ANALYZE-driven approximations. Use this first.\n- `approx`: uses pgstattuple_approx() -- fast sampling pass, more accurate than estimates, requires the pgstattuple extension.\n- `exact`: uses pgstattuple() -- full table scan, exact counts, slow on large tables, requires the pgstattuple extension. Always pass `schema` with method='exact' -- scanning all user tables in one statement will hit statement_timeout on non-trivial databases.\nInstall pgstattuple with `CREATE EXTENSION pgstattuple` (requires superuser).",
|
|
36769
36800
|
annotations: {
|
|
36770
36801
|
title: "Estimate table bloat",
|
|
36771
36802
|
readOnlyHint: true,
|
|
@@ -36776,13 +36807,60 @@ var adminTools = [
|
|
|
36776
36807
|
inputSchema: external_exports.object({
|
|
36777
36808
|
schema: identSchema.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
36778
36809
|
minDeadRatio: external_exports.number().min(0).max(1).default(0.1).describe("Minimum dead-tuple fraction to include - dead / (live + dead). Default 0.1 = 10%."),
|
|
36779
|
-
limit: external_exports.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
|
|
36810
|
+
limit: external_exports.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50)."),
|
|
36811
|
+
method: external_exports.enum(["estimate", "approx", "exact"]).default("estimate").describe(
|
|
36812
|
+
"Bloat measurement method. 'estimate' (default) uses pg_stat_user_tables (fast, no extensions). 'approx' uses pgstattuple_approx() (fast sampling, more accurate). 'exact' uses pgstattuple() (full scan, exact but slow). Both 'approx' and 'exact' require the pgstattuple extension."
|
|
36813
|
+
)
|
|
36780
36814
|
}),
|
|
36781
36815
|
handler: async (input) => {
|
|
36782
|
-
const {
|
|
36816
|
+
const {
|
|
36817
|
+
schema,
|
|
36818
|
+
minDeadRatio,
|
|
36819
|
+
limit,
|
|
36820
|
+
method: rawMethod
|
|
36821
|
+
} = input;
|
|
36822
|
+
const method = rawMethod ?? "estimate";
|
|
36783
36823
|
const schemaFilter = schema ? "AND schemaname = $3" : "AND schemaname NOT IN ('pg_catalog', 'information_schema') AND schemaname NOT LIKE 'pg_%'";
|
|
36784
36824
|
const params = [minDeadRatio, limit];
|
|
36785
36825
|
if (schema) params.push(schema);
|
|
36826
|
+
if (method !== "estimate") {
|
|
36827
|
+
const check2 = await runInternal(
|
|
36828
|
+
`SELECT EXISTS (
|
|
36829
|
+
SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pgstattuple'
|
|
36830
|
+
) AS installed`
|
|
36831
|
+
);
|
|
36832
|
+
if (!check2.ok) return check2;
|
|
36833
|
+
if (!check2.data?.[0]?.installed) {
|
|
36834
|
+
return {
|
|
36835
|
+
ok: false,
|
|
36836
|
+
error: `pgstattuple extension is not installed. Install with \`CREATE EXTENSION pgstattuple;\` (requires superuser), then retry with method='${method}'.`
|
|
36837
|
+
};
|
|
36838
|
+
}
|
|
36839
|
+
const fn = method === "approx" ? "pgstattuple_approx" : "pgstattuple";
|
|
36840
|
+
const liveTuplesCol = method === "approx" ? "approx_tuple_count" : "tuple_count";
|
|
36841
|
+
return runInternal(
|
|
36842
|
+
`SELECT
|
|
36843
|
+
s.schemaname AS schema,
|
|
36844
|
+
s.relname AS "table",
|
|
36845
|
+
(p.${liveTuplesCol})::text AS live_tuples,
|
|
36846
|
+
p.dead_tuple_count::text AS dead_tuples,
|
|
36847
|
+
(p.dead_tuple_count::float8 / NULLIF(p.${liveTuplesCol} + p.dead_tuple_count, 0))::numeric(6, 3)::float8 AS dead_ratio,
|
|
36848
|
+
pg_size_pretty(pg_total_relation_size(s.relid)) AS size_pretty,
|
|
36849
|
+
pg_total_relation_size(s.relid)::text AS size_bytes,
|
|
36850
|
+
s.last_vacuum::text AS last_vacuum,
|
|
36851
|
+
s.last_autovacuum::text AS last_autovacuum,
|
|
36852
|
+
s.last_analyze::text AS last_analyze
|
|
36853
|
+
FROM pg_catalog.pg_stat_user_tables s
|
|
36854
|
+
JOIN pg_catalog.pg_class c ON c.oid = s.relid AND c.relkind IN ('r', 'm')
|
|
36855
|
+
CROSS JOIN LATERAL ${fn}(s.relid::regclass) p
|
|
36856
|
+
WHERE (p.${liveTuplesCol} + p.dead_tuple_count) > 0
|
|
36857
|
+
AND (p.dead_tuple_count::float8 / NULLIF(p.${liveTuplesCol} + p.dead_tuple_count, 0)) >= $1
|
|
36858
|
+
${schemaFilter}
|
|
36859
|
+
ORDER BY p.dead_tuple_count DESC
|
|
36860
|
+
LIMIT $2`,
|
|
36861
|
+
params
|
|
36862
|
+
);
|
|
36863
|
+
}
|
|
36786
36864
|
return runInternal(
|
|
36787
36865
|
// dead_ratio = dead / (live + dead): bounded [0, 1]. A 100%-dead table
|
|
36788
36866
|
// (live=0, dead>0) correctly reports 1.0 instead of 0. Tables with both
|
|
@@ -36828,10 +36906,14 @@ function quoteQualifiedTable(name) {
|
|
|
36828
36906
|
return name.split(".").map((p) => quoteIdent(p)).join(".");
|
|
36829
36907
|
}
|
|
36830
36908
|
function validateHypoIndex(idx) {
|
|
36831
|
-
|
|
36832
|
-
|
|
36833
|
-
|
|
36834
|
-
|
|
36909
|
+
if (idx.table.includes('"')) {
|
|
36910
|
+
return `Hypothetical index table ${JSON.stringify(idx.table)} contains a double-quote; pass plain identifier names without pre-quoting.`;
|
|
36911
|
+
}
|
|
36912
|
+
const pieces = idx.table.split(".");
|
|
36913
|
+
if (pieces.length > 2) {
|
|
36914
|
+
return `Hypothetical index table ${JSON.stringify(idx.table)} is over-qualified; use only \`schema.table\` or \`table\`.`;
|
|
36915
|
+
}
|
|
36916
|
+
for (const piece of pieces) {
|
|
36835
36917
|
if (Buffer.byteLength(piece, "utf8") > 63) {
|
|
36836
36918
|
return `Hypothetical index table piece ${JSON.stringify(piece)} exceeds PostgreSQL's 63-byte NAMEDATALEN limit (multi-byte characters count as multiple bytes).`;
|
|
36837
36919
|
}
|
|
@@ -36889,7 +36971,15 @@ var explainTools = [
|
|
|
36889
36971
|
)
|
|
36890
36972
|
}),
|
|
36891
36973
|
handler: async (input) => {
|
|
36892
|
-
const {
|
|
36974
|
+
const {
|
|
36975
|
+
sql,
|
|
36976
|
+
analyze: rawAnalyze,
|
|
36977
|
+
format: rawFormat,
|
|
36978
|
+
params,
|
|
36979
|
+
hypothetical_indexes
|
|
36980
|
+
} = input;
|
|
36981
|
+
const analyze = rawAnalyze ?? false;
|
|
36982
|
+
const format = rawFormat ?? "text";
|
|
36893
36983
|
if (/^\s*EXPLAIN\b/i.test(sql)) {
|
|
36894
36984
|
return {
|
|
36895
36985
|
ok: false,
|
|
@@ -36929,6 +37019,9 @@ var explainTools = [
|
|
|
36929
37019
|
}
|
|
36930
37020
|
if (format === "text") {
|
|
36931
37021
|
const lines = rows.map((r) => String(r["QUERY PLAN"] ?? ""));
|
|
37022
|
+
if (result.data.truncated) {
|
|
37023
|
+
lines.push(`... [plan truncated at ${rows.length} lines; raise POSTGRES_MAX_ROWS to see the full plan]`);
|
|
37024
|
+
}
|
|
36932
37025
|
return { ok: true, data: { plan: lines.join("\n") } };
|
|
36933
37026
|
}
|
|
36934
37027
|
const jsonPlan = rows[0]?.["QUERY PLAN"];
|
|
@@ -37000,6 +37093,9 @@ var healthTools = [
|
|
|
37000
37093
|
]);
|
|
37001
37094
|
if (!versionRes.ok) return versionRes;
|
|
37002
37095
|
const warnings = [];
|
|
37096
|
+
if (versionRes.data?.[0]?.version === void 0) {
|
|
37097
|
+
warnings.push(`version unavailable despite successful query`);
|
|
37098
|
+
}
|
|
37003
37099
|
if (!sizeRes.ok) warnings.push(`database fetch failed: ${sizeRes.error}`);
|
|
37004
37100
|
if (!connsRes.ok) warnings.push(`connections fetch failed: ${connsRes.error}`);
|
|
37005
37101
|
if (!activeRes.ok) warnings.push(`active_queries fetch failed: ${activeRes.error}`);
|
|
@@ -37044,7 +37140,7 @@ var queryTools = [
|
|
|
37044
37140
|
},
|
|
37045
37141
|
{
|
|
37046
37142
|
name: "pg_query",
|
|
37047
|
-
description: "Run a SQL query against the configured PostgreSQL database.
|
|
37143
|
+
description: "Run a SQL query against the configured PostgreSQL database. Postgres itself is the primary safety gate: the role in `DATABASE_URL` enforces what queries can succeed. The recommended posture is a least-privileged role (e.g. one granted `pg_read_all_data`), which makes writes server-rejected regardless of any env var. `ALLOW_WRITES=1` is a secondary belt-and-braces gate - it lifts the in-server `BEGIN READ ONLY` wrapper, but it cannot grant privileges the role lacks. Useful for managed databases where creating a second role is awkward. For read-only access where you want the guarantee in the tool name, prefer `pg_readonly`. Use `params` for parameterized queries to avoid SQL injection. Params can be strings, numbers, booleans, null, arrays (for postgres arrays / ANY), or objects (for json/jsonb columns). Dates and UUIDs can be passed as ISO strings. Large result sets are truncated to POSTGRES_MAX_ROWS (default 1000) with a `truncated: true` flag.",
|
|
37048
37144
|
annotations: {
|
|
37049
37145
|
title: "Run SQL query",
|
|
37050
37146
|
readOnlyHint: false,
|
|
@@ -37095,7 +37191,7 @@ var schemaTools = [
|
|
|
37095
37191
|
},
|
|
37096
37192
|
{
|
|
37097
37193
|
name: "pg_list_tables",
|
|
37098
|
-
description: "List tables (and optionally views) in a schema. Returns name, type (table/view/materialized view/foreign), and estimated row count (from `reltuples`;
|
|
37194
|
+
description: "List tables (and optionally views) in a schema. Returns name, type (table/view/materialized view/foreign), and estimated row count (from `reltuples`; null = no ANALYZE yet on PG 14+; 0 may mean empty or unanalyzed on PG <= 13). Paginate via `limit`/`offset` on very large schemas.",
|
|
37099
37195
|
annotations: {
|
|
37100
37196
|
title: "List tables in a schema",
|
|
37101
37197
|
readOnlyHint: true,
|
|
@@ -37123,7 +37219,7 @@ var schemaTools = [
|
|
|
37123
37219
|
WHEN 'p' THEN 'partitioned_table'
|
|
37124
37220
|
ELSE c.relkind::text
|
|
37125
37221
|
END AS type,
|
|
37126
|
-
c.reltuples::
|
|
37222
|
+
NULLIF(round(c.reltuples), -1)::float8 AS estimated_rows
|
|
37127
37223
|
FROM pg_catalog.pg_class c
|
|
37128
37224
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
37129
37225
|
WHERE n.nspname = $1
|
|
@@ -37204,6 +37300,10 @@ var schemaTools = [
|
|
|
37204
37300
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
37205
37301
|
JOIN pg_catalog.pg_class cl ON cl.oid = con.confrelid
|
|
37206
37302
|
JOIN pg_catalog.pg_namespace fn ON fn.oid = cl.relnamespace
|
|
37303
|
+
-- Pairing local conkey[i] to foreign confkey[i] by ordinality relies on
|
|
37304
|
+
-- the postgres invariant that conkey[i] references confkey[i]. unnest
|
|
37305
|
+
-- WITH ORDINALITY preserves array order; reordering or dropping
|
|
37306
|
+
-- WITH ORDINALITY would silently mispair composite-FK columns.
|
|
37207
37307
|
JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS u(attnum, attposition) ON TRUE
|
|
37208
37308
|
JOIN pg_catalog.pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = u.attnum
|
|
37209
37309
|
JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS fu(attnum, attposition) ON fu.attposition = u.attposition
|
|
@@ -37258,6 +37358,10 @@ var schemaTools = [
|
|
|
37258
37358
|
JOIN pg_catalog.pg_namespace srcn ON srcn.oid = src.relnamespace
|
|
37259
37359
|
JOIN pg_catalog.pg_class ref ON ref.oid = con.confrelid
|
|
37260
37360
|
JOIN pg_catalog.pg_namespace refn ON refn.oid = ref.relnamespace
|
|
37361
|
+
-- Pairing local conkey[i] to foreign confkey[i] by ordinality relies on
|
|
37362
|
+
-- the postgres invariant that conkey[i] references confkey[i]. unnest
|
|
37363
|
+
-- WITH ORDINALITY preserves array order; reordering or dropping
|
|
37364
|
+
-- WITH ORDINALITY would silently mispair composite-FK columns.
|
|
37261
37365
|
JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS u(attnum, attposition) ON TRUE
|
|
37262
37366
|
JOIN pg_catalog.pg_attribute srcatt ON srcatt.attrelid = con.conrelid AND srcatt.attnum = u.attnum
|
|
37263
37367
|
JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS fu(attnum, attposition) ON fu.attposition = u.attposition
|
|
@@ -37318,6 +37422,7 @@ var schemaTools = [
|
|
|
37318
37422
|
const kind = kindRes.ok ? kindRes.data?.[0]?.kind ?? "table" : "table";
|
|
37319
37423
|
const warnings = [];
|
|
37320
37424
|
if (!kindRes.ok) warnings.push(`kind fetch failed, reported as "table": ${kindRes.error}`);
|
|
37425
|
+
else if ((kindRes.data?.length ?? 0) === 0) warnings.push(`kind unavailable, reported as "table"`);
|
|
37321
37426
|
if (!pk.ok) warnings.push(`primary_key fetch failed: ${pk.error}`);
|
|
37322
37427
|
if (!fks.ok) warnings.push(`foreign_keys fetch failed: ${fks.error}`);
|
|
37323
37428
|
if (!idxs.ok) warnings.push(`indexes fetch failed: ${idxs.error}`);
|
|
@@ -37486,7 +37591,7 @@ var schemaTools = [
|
|
|
37486
37591
|
var statsTools = [
|
|
37487
37592
|
{
|
|
37488
37593
|
name: "pg_top_queries",
|
|
37489
|
-
description: "Top N queries by total or mean execution time. Requires the `pg_stat_statements` extension to be installed and enabled (most managed Postgres providers have it on by default). Returns normalized query text (constants replaced with `?`), call count, total/mean/min/max time in ms, rows returned, and cache hit ratio. Use this to find slow queries worth optimizing.",
|
|
37594
|
+
description: "Top N queries by total or mean execution time. Requires the `pg_stat_statements` extension to be installed and enabled (most managed Postgres providers have it on by default). Returns normalized query text (constants replaced with `?`), call count, total/mean/min/max time in ms, rows returned, and cache hit ratio. Use this to find slow queries worth optimizing. On pg_stat_statements >= 1.10 (Postgres 15+), also returns `io_read_time_ms` and `io_write_time_ms` to separate IO-bound from CPU-bound queries (null when track_io_timing = off or the query did no measurable IO -- enable track_io_timing in postgresql.conf to get non-null values).",
|
|
37490
37595
|
annotations: {
|
|
37491
37596
|
title: "Top queries by execution time",
|
|
37492
37597
|
readOnlyHint: true,
|
|
@@ -37516,6 +37621,11 @@ var statsTools = [
|
|
|
37516
37621
|
const meanCol = useExecSuffix ? "mean_exec_time" : "mean_time";
|
|
37517
37622
|
const minCol = useExecSuffix ? "min_exec_time" : "min_time";
|
|
37518
37623
|
const maxCol = useExecSuffix ? "max_exec_time" : "max_time";
|
|
37624
|
+
const hasIoTiming = compareVersions(extVersion, "1.10") >= 0;
|
|
37625
|
+
const hasSharedBlkCols = compareVersions(extVersion, "1.11") >= 0;
|
|
37626
|
+
const ioTimingCols = hasIoTiming ? `,
|
|
37627
|
+
NULLIF(${hasSharedBlkCols ? "shared_blk_read_time" : "blk_read_time"}, 0)::numeric(18, 2)::float8 AS io_read_time_ms,
|
|
37628
|
+
NULLIF(${hasSharedBlkCols ? "shared_blk_write_time" : "blk_write_time"}, 0)::numeric(18, 2)::float8 AS io_write_time_ms` : "";
|
|
37519
37629
|
const orderCol = orderBy === "total_time" ? totalCol : orderBy === "mean_time" ? meanCol : "pg_stat_statements.calls";
|
|
37520
37630
|
return runInternal(
|
|
37521
37631
|
// bigint counters (calls, rows) come back as `.text` for lossless
|
|
@@ -37534,7 +37644,7 @@ var statsTools = [
|
|
|
37534
37644
|
WHEN (shared_blks_hit + shared_blks_read) > 0
|
|
37535
37645
|
THEN (shared_blks_hit::float8 / (shared_blks_hit + shared_blks_read) * 100)::numeric(5, 2)::float8
|
|
37536
37646
|
ELSE NULL
|
|
37537
|
-
END AS hit_percent
|
|
37647
|
+
END AS hit_percent${ioTimingCols}
|
|
37538
37648
|
FROM pg_stat_statements
|
|
37539
37649
|
ORDER BY ${orderCol} DESC NULLS LAST
|
|
37540
37650
|
LIMIT $1`,
|
|
@@ -37641,7 +37751,7 @@ function compareVersions(a, b) {
|
|
|
37641
37751
|
}
|
|
37642
37752
|
|
|
37643
37753
|
// src/index.ts
|
|
37644
|
-
var version2 = true ? "0.
|
|
37754
|
+
var version2 = true ? "0.7.0" : await readPackageVersion();
|
|
37645
37755
|
var subcommand = process.argv[2];
|
|
37646
37756
|
if (subcommand === "version" || subcommand === "--version") {
|
|
37647
37757
|
console.log(version2);
|
|
@@ -37662,10 +37772,18 @@ for (const tool of allTools) {
|
|
|
37662
37772
|
);
|
|
37663
37773
|
}
|
|
37664
37774
|
var transport = new StdioServerTransport();
|
|
37665
|
-
|
|
37666
|
-
|
|
37667
|
-
console.error(`@yawlabs/postgres-mcp v${version2} ready (${allTools.length} tools, ${writesNote})`);
|
|
37775
|
+
server.connect(transport).then(() => {
|
|
37776
|
+
const writesNote = isWritesAllowed() ? "writes ENABLED" : "read-only";
|
|
37777
|
+
console.error(`@yawlabs/postgres-mcp v${version2} ready (${allTools.length} tools, ${writesNote})`);
|
|
37778
|
+
}).catch((err) => {
|
|
37779
|
+
process.stderr.write(`postgres-mcp: ${err instanceof Error ? err.message : String(err)}
|
|
37780
|
+
`);
|
|
37781
|
+
process.exit(1);
|
|
37782
|
+
});
|
|
37783
|
+
var exiting = false;
|
|
37668
37784
|
var cleanup = async () => {
|
|
37785
|
+
if (exiting) return;
|
|
37786
|
+
exiting = true;
|
|
37669
37787
|
try {
|
|
37670
37788
|
await shutdown();
|
|
37671
37789
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/postgres-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"mcpName": "io.github.YawLabs/postgres-mcp",
|
|
5
5
|
"description": "PostgreSQL MCP server - query, schema introspection, explain, and health checks for AI assistants",
|
|
6
6
|
"license": "MIT",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"@types/pg": "^8.20.0",
|
|
55
55
|
"esbuild": "^0.28.0",
|
|
56
56
|
"pg": "^8.14.0",
|
|
57
|
+
"postject": "^1.0.0-alpha.6",
|
|
57
58
|
"typescript": "^6.0.3",
|
|
58
59
|
"zod": "^4.3.6"
|
|
59
60
|
},
|