@yawlabs/postgres-mcp 0.6.20 → 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 CHANGED
@@ -88,6 +88,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
88
  a local-only pre-tag gate; this is just an opt-in fail-fast.
89
89
  - `package.json` `package-lock.json` and `server.json` all bumped to 0.6.20.
90
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
+
91
142
  ## [0.6.16] - 2026-05-18
92
143
 
93
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
@@ -36691,7 +36691,7 @@ var adminTools = [
36691
36691
  },
36692
36692
  {
36693
36693
  name: "pg_advisor",
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 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.",
36695
36695
  annotations: {
36696
36696
  title: "Database advisor (DBA lints)",
36697
36697
  readOnlyHint: true,
@@ -36744,6 +36744,10 @@ var adminTools = [
36744
36744
  // Partition children (relkind='r') inherit the parent's PK as an
36745
36745
  // indisprimary index on the child, so the NOT EXISTS clause keeps
36746
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.
36747
36751
  `SELECT
36748
36752
  n.nspname AS schema,
36749
36753
  c.relname AS "table"
@@ -36792,7 +36796,7 @@ var adminTools = [
36792
36796
  },
36793
36797
  {
36794
36798
  name: "pg_table_bloat",
36795
- 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 - uses `pg_stat_user_tables`, no extensions required.",
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).",
36796
36800
  annotations: {
36797
36801
  title: "Estimate table bloat",
36798
36802
  readOnlyHint: true,
@@ -36803,13 +36807,60 @@ var adminTools = [
36803
36807
  inputSchema: external_exports.object({
36804
36808
  schema: identSchema.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
36805
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%."),
36806
- 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
+ )
36807
36814
  }),
36808
36815
  handler: async (input) => {
36809
- const { schema, minDeadRatio, limit } = input;
36816
+ const {
36817
+ schema,
36818
+ minDeadRatio,
36819
+ limit,
36820
+ method: rawMethod
36821
+ } = input;
36822
+ const method = rawMethod ?? "estimate";
36810
36823
  const schemaFilter = schema ? "AND schemaname = $3" : "AND schemaname NOT IN ('pg_catalog', 'information_schema') AND schemaname NOT LIKE 'pg_%'";
36811
36824
  const params = [minDeadRatio, limit];
36812
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
+ }
36813
36864
  return runInternal(
36814
36865
  // dead_ratio = dead / (live + dead): bounded [0, 1]. A 100%-dead table
36815
36866
  // (live=0, dead>0) correctly reports 1.0 instead of 0. Tables with both
@@ -36855,14 +36906,14 @@ function quoteQualifiedTable(name) {
36855
36906
  return name.split(".").map((p) => quoteIdent(p)).join(".");
36856
36907
  }
36857
36908
  function validateHypoIndex(idx) {
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
+ }
36858
36912
  const pieces = idx.table.split(".");
36859
36913
  if (pieces.length > 2) {
36860
36914
  return `Hypothetical index table ${JSON.stringify(idx.table)} is over-qualified; use only \`schema.table\` or \`table\`.`;
36861
36915
  }
36862
36916
  for (const piece of pieces) {
36863
- if (piece.includes('"')) {
36864
- return `Hypothetical index table ${JSON.stringify(idx.table)} contains a double-quote; pass plain identifier names without pre-quoting.`;
36865
- }
36866
36917
  if (Buffer.byteLength(piece, "utf8") > 63) {
36867
36918
  return `Hypothetical index table piece ${JSON.stringify(piece)} exceeds PostgreSQL's 63-byte NAMEDATALEN limit (multi-byte characters count as multiple bytes).`;
36868
36919
  }
@@ -37140,7 +37191,7 @@ var schemaTools = [
37140
37191
  },
37141
37192
  {
37142
37193
  name: "pg_list_tables",
37143
- description: "List tables (and optionally views) in a schema. Returns name, type (table/view/materialized view/foreign), and estimated row count (from `reltuples`; approximate - 0 until ANALYZE runs). Paginate via `limit`/`offset` on very large schemas.",
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.",
37144
37195
  annotations: {
37145
37196
  title: "List tables in a schema",
37146
37197
  readOnlyHint: true,
@@ -37168,7 +37219,7 @@ var schemaTools = [
37168
37219
  WHEN 'p' THEN 'partitioned_table'
37169
37220
  ELSE c.relkind::text
37170
37221
  END AS type,
37171
- c.reltuples::bigint AS estimated_rows
37222
+ NULLIF(round(c.reltuples), -1)::float8 AS estimated_rows
37172
37223
  FROM pg_catalog.pg_class c
37173
37224
  JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
37174
37225
  WHERE n.nspname = $1
@@ -37540,7 +37591,7 @@ var schemaTools = [
37540
37591
  var statsTools = [
37541
37592
  {
37542
37593
  name: "pg_top_queries",
37543
- 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).",
37544
37595
  annotations: {
37545
37596
  title: "Top queries by execution time",
37546
37597
  readOnlyHint: true,
@@ -37570,6 +37621,11 @@ var statsTools = [
37570
37621
  const meanCol = useExecSuffix ? "mean_exec_time" : "mean_time";
37571
37622
  const minCol = useExecSuffix ? "min_exec_time" : "min_time";
37572
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` : "";
37573
37629
  const orderCol = orderBy === "total_time" ? totalCol : orderBy === "mean_time" ? meanCol : "pg_stat_statements.calls";
37574
37630
  return runInternal(
37575
37631
  // bigint counters (calls, rows) come back as `.text` for lossless
@@ -37588,7 +37644,7 @@ var statsTools = [
37588
37644
  WHEN (shared_blks_hit + shared_blks_read) > 0
37589
37645
  THEN (shared_blks_hit::float8 / (shared_blks_hit + shared_blks_read) * 100)::numeric(5, 2)::float8
37590
37646
  ELSE NULL
37591
- END AS hit_percent
37647
+ END AS hit_percent${ioTimingCols}
37592
37648
  FROM pg_stat_statements
37593
37649
  ORDER BY ${orderCol} DESC NULLS LAST
37594
37650
  LIMIT $1`,
@@ -37695,7 +37751,7 @@ function compareVersions(a, b) {
37695
37751
  }
37696
37752
 
37697
37753
  // src/index.ts
37698
- var version2 = true ? "0.6.20" : await readPackageVersion();
37754
+ var version2 = true ? "0.7.0" : await readPackageVersion();
37699
37755
  var subcommand = process.argv[2];
37700
37756
  if (subcommand === "version" || subcommand === "--version") {
37701
37757
  console.log(version2);
@@ -37716,9 +37772,14 @@ for (const tool of allTools) {
37716
37772
  );
37717
37773
  }
37718
37774
  var transport = new StdioServerTransport();
37719
- await server.connect(transport);
37720
- var writesNote = isWritesAllowed() ? "writes ENABLED" : "read-only";
37721
- 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
+ });
37722
37783
  var exiting = false;
37723
37784
  var cleanup = async () => {
37724
37785
  if (exiting) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/postgres-mcp",
3
- "version": "0.6.20",
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
  },