@yawlabs/postgres-mcp 0.6.18 → 0.6.20

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
@@ -7,6 +7,87 @@ 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
+
10
91
  ## [0.6.16] - 2026-05-18
11
92
 
12
93
  ### Tests
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  Built and maintained by [Yaw Labs](https://yaw.sh).
9
9
 
10
- [![Add to Yaw MCP](https://yaw.sh/yaw-mcp-button.svg)](yaw://install?name=Postgres&command=npx&args=-y%2C%40yawlabs%2Fpostgres-mcp&description=Query%20PostgreSQL%20-%20schema%20introspection%2C%20EXPLAIN%20plans%2C%20health%20diagnostics%2C%20read-only%20by%20default&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fpostgres-mcp)
10
+ [![Add to Yaw MCP](https://yaw.sh/yaw-mcp-button.svg)](https://yaw.sh/mcp/install?name=Postgres&command=npx&args=-y%2C%40yawlabs%2Fpostgres-mcp&description=Query%20PostgreSQL%20-%20schema%20introspection%2C%20EXPLAIN%20plans%2C%20health%20diagnostics%2C%20read-only%20by%20default&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fpostgres-mcp)
11
11
 
12
12
  One click adds this to your local Yaw MCP config so it's available in every Yaw Terminal session. Or install manually below.
13
13
 
package/dist/index.js CHANGED
@@ -36349,9 +36349,62 @@ async function shutdown() {
36349
36349
  }
36350
36350
  }
36351
36351
 
36352
+ // src/mcp-wrapper.ts
36353
+ function wrapToolHandler(handler) {
36354
+ return async (input) => {
36355
+ try {
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
+ }
36368
+ const response = result;
36369
+ if (!response.ok) {
36370
+ return {
36371
+ content: [
36372
+ {
36373
+ type: "text",
36374
+ text: `Error: ${response.error || "Unknown error"}`
36375
+ }
36376
+ ],
36377
+ isError: true
36378
+ };
36379
+ }
36380
+ const text = JSON.stringify(
36381
+ response.data ?? { success: true },
36382
+ (_k, v) => typeof v === "bigint" ? v.toString() : v,
36383
+ 2
36384
+ );
36385
+ return {
36386
+ content: [{ type: "text", text }]
36387
+ };
36388
+ } catch (err) {
36389
+ const message = err instanceof Error ? err.message : String(err);
36390
+ return {
36391
+ content: [{ type: "text", text: `Error: ${message}` }],
36392
+ isError: true
36393
+ };
36394
+ }
36395
+ };
36396
+ }
36397
+
36352
36398
  // src/tools/params.ts
36353
36399
  var paramValue = external_exports.lazy(
36354
- () => external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null(), external_exports.array(paramValue), external_exports.record(external_exports.string(), paramValue)])
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
+ ])
36355
36408
  );
36356
36409
  var MAX_PARAM_DEPTH = 32;
36357
36410
  function isWithinDepth(value, maxDepth) {
@@ -36372,7 +36425,7 @@ function isWithinDepth(value, maxDepth) {
36372
36425
  var paramsArray = external_exports.array(paramValue).refine((arr) => arr.every((v) => isWithinDepth(v, MAX_PARAM_DEPTH)), {
36373
36426
  message: `Parameter value exceeds maximum nesting depth of ${MAX_PARAM_DEPTH}`
36374
36427
  });
36375
- var identSchema = external_exports.string().min(1).max(63).refine((v) => Buffer.byteLength(v, "utf8") <= 63, {
36428
+ var identSchema = external_exports.string().min(1).refine((v) => Buffer.byteLength(v, "utf8") <= 63, {
36376
36429
  message: "Identifier exceeds PostgreSQL's 63-byte NAMEDATALEN limit (multi-byte characters count as multiple bytes)."
36377
36430
  });
36378
36431
 
@@ -36380,7 +36433,7 @@ var identSchema = external_exports.string().min(1).max(63).refine((v) => Buffer.
36380
36433
  var adminTools = [
36381
36434
  {
36382
36435
  name: "pg_inspect_locks",
36383
- 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.",
36384
36437
  annotations: {
36385
36438
  title: "Inspect blocking locks",
36386
36439
  readOnlyHint: true,
@@ -36516,7 +36569,7 @@ var adminTools = [
36516
36569
  table_name AS "table",
36517
36570
  grantee,
36518
36571
  privilege_type,
36519
- is_grantable::boolean AS is_grantable
36572
+ (is_grantable = 'YES') AS is_grantable
36520
36573
  FROM information_schema.table_privileges
36521
36574
  WHERE table_schema = $1
36522
36575
  ${tableFilter}
@@ -36661,6 +36714,11 @@ var adminTools = [
36661
36714
  // Divide in `numeric`, not `float8`: BIGINT sequences past 2^53 lose
36662
36715
  // precision in float8, and the danger zone (>= threshold) is exactly
36663
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.
36664
36722
  `SELECT
36665
36723
  schemaname AS schema,
36666
36724
  sequencename AS sequence,
@@ -36797,7 +36855,11 @@ function quoteQualifiedTable(name) {
36797
36855
  return name.split(".").map((p) => quoteIdent(p)).join(".");
36798
36856
  }
36799
36857
  function validateHypoIndex(idx) {
36800
- for (const piece of idx.table.split(".")) {
36858
+ const pieces = idx.table.split(".");
36859
+ if (pieces.length > 2) {
36860
+ return `Hypothetical index table ${JSON.stringify(idx.table)} is over-qualified; use only \`schema.table\` or \`table\`.`;
36861
+ }
36862
+ for (const piece of pieces) {
36801
36863
  if (piece.includes('"')) {
36802
36864
  return `Hypothetical index table ${JSON.stringify(idx.table)} contains a double-quote; pass plain identifier names without pre-quoting.`;
36803
36865
  }
@@ -36858,7 +36920,15 @@ var explainTools = [
36858
36920
  )
36859
36921
  }),
36860
36922
  handler: async (input) => {
36861
- const { sql, analyze, format, params, hypothetical_indexes } = input;
36923
+ const {
36924
+ sql,
36925
+ analyze: rawAnalyze,
36926
+ format: rawFormat,
36927
+ params,
36928
+ hypothetical_indexes
36929
+ } = input;
36930
+ const analyze = rawAnalyze ?? false;
36931
+ const format = rawFormat ?? "text";
36862
36932
  if (/^\s*EXPLAIN\b/i.test(sql)) {
36863
36933
  return {
36864
36934
  ok: false,
@@ -36898,6 +36968,9 @@ var explainTools = [
36898
36968
  }
36899
36969
  if (format === "text") {
36900
36970
  const lines = rows.map((r) => String(r["QUERY PLAN"] ?? ""));
36971
+ if (result.data.truncated) {
36972
+ lines.push(`... [plan truncated at ${rows.length} lines; raise POSTGRES_MAX_ROWS to see the full plan]`);
36973
+ }
36901
36974
  return { ok: true, data: { plan: lines.join("\n") } };
36902
36975
  }
36903
36976
  const jsonPlan = rows[0]?.["QUERY PLAN"];
@@ -36969,6 +37042,9 @@ var healthTools = [
36969
37042
  ]);
36970
37043
  if (!versionRes.ok) return versionRes;
36971
37044
  const warnings = [];
37045
+ if (versionRes.data?.[0]?.version === void 0) {
37046
+ warnings.push(`version unavailable despite successful query`);
37047
+ }
36972
37048
  if (!sizeRes.ok) warnings.push(`database fetch failed: ${sizeRes.error}`);
36973
37049
  if (!connsRes.ok) warnings.push(`connections fetch failed: ${connsRes.error}`);
36974
37050
  if (!activeRes.ok) warnings.push(`active_queries fetch failed: ${activeRes.error}`);
@@ -37013,7 +37089,7 @@ var queryTools = [
37013
37089
  },
37014
37090
  {
37015
37091
  name: "pg_query",
37016
- description: "Run a SQL query against the configured PostgreSQL database. Writes are gated by the role in `DATABASE_URL` first and `ALLOW_WRITES` second: a role created with `GRANT pg_read_all_data` makes writes server-rejected regardless of `ALLOW_WRITES`, and is the recommended way to scope agent access. `ALLOW_WRITES=1` is a secondary belt-and-braces gate - 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.",
37092
+ 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.",
37017
37093
  annotations: {
37018
37094
  title: "Run SQL query",
37019
37095
  readOnlyHint: false,
@@ -37173,6 +37249,10 @@ var schemaTools = [
37173
37249
  JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
37174
37250
  JOIN pg_catalog.pg_class cl ON cl.oid = con.confrelid
37175
37251
  JOIN pg_catalog.pg_namespace fn ON fn.oid = cl.relnamespace
37252
+ -- Pairing local conkey[i] to foreign confkey[i] by ordinality relies on
37253
+ -- the postgres invariant that conkey[i] references confkey[i]. unnest
37254
+ -- WITH ORDINALITY preserves array order; reordering or dropping
37255
+ -- WITH ORDINALITY would silently mispair composite-FK columns.
37176
37256
  JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS u(attnum, attposition) ON TRUE
37177
37257
  JOIN pg_catalog.pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = u.attnum
37178
37258
  JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS fu(attnum, attposition) ON fu.attposition = u.attposition
@@ -37227,6 +37307,10 @@ var schemaTools = [
37227
37307
  JOIN pg_catalog.pg_namespace srcn ON srcn.oid = src.relnamespace
37228
37308
  JOIN pg_catalog.pg_class ref ON ref.oid = con.confrelid
37229
37309
  JOIN pg_catalog.pg_namespace refn ON refn.oid = ref.relnamespace
37310
+ -- Pairing local conkey[i] to foreign confkey[i] by ordinality relies on
37311
+ -- the postgres invariant that conkey[i] references confkey[i]. unnest
37312
+ -- WITH ORDINALITY preserves array order; reordering or dropping
37313
+ -- WITH ORDINALITY would silently mispair composite-FK columns.
37230
37314
  JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS u(attnum, attposition) ON TRUE
37231
37315
  JOIN pg_catalog.pg_attribute srcatt ON srcatt.attrelid = con.conrelid AND srcatt.attnum = u.attnum
37232
37316
  JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS fu(attnum, attposition) ON fu.attposition = u.attposition
@@ -37287,6 +37371,7 @@ var schemaTools = [
37287
37371
  const kind = kindRes.ok ? kindRes.data?.[0]?.kind ?? "table" : "table";
37288
37372
  const warnings = [];
37289
37373
  if (!kindRes.ok) warnings.push(`kind fetch failed, reported as "table": ${kindRes.error}`);
37374
+ else if ((kindRes.data?.length ?? 0) === 0) warnings.push(`kind unavailable, reported as "table"`);
37290
37375
  if (!pk.ok) warnings.push(`primary_key fetch failed: ${pk.error}`);
37291
37376
  if (!fks.ok) warnings.push(`foreign_keys fetch failed: ${fks.error}`);
37292
37377
  if (!idxs.ok) warnings.push(`indexes fetch failed: ${idxs.error}`);
@@ -37610,7 +37695,7 @@ function compareVersions(a, b) {
37610
37695
  }
37611
37696
 
37612
37697
  // src/index.ts
37613
- var version2 = true ? "0.6.18" : (await null).createRequire(import.meta.url)("../package.json").version;
37698
+ var version2 = true ? "0.6.20" : await readPackageVersion();
37614
37699
  var subcommand = process.argv[2];
37615
37700
  if (subcommand === "version" || subcommand === "--version") {
37616
37701
  console.log(version2);
@@ -37627,40 +37712,17 @@ for (const tool of allTools) {
37627
37712
  tool.description,
37628
37713
  tool.inputSchema.shape,
37629
37714
  tool.annotations,
37630
- async (input) => {
37631
- try {
37632
- const result = await tool.handler(input);
37633
- const response = result;
37634
- if (!response.ok) {
37635
- return {
37636
- content: [
37637
- {
37638
- type: "text",
37639
- text: `Error: ${response.error || "Unknown error"}`
37640
- }
37641
- ],
37642
- isError: true
37643
- };
37644
- }
37645
- const text = JSON.stringify(response.data ?? { success: true }, null, 2);
37646
- return {
37647
- content: [{ type: "text", text }]
37648
- };
37649
- } catch (err) {
37650
- const message = err instanceof Error ? err.message : String(err);
37651
- return {
37652
- content: [{ type: "text", text: `Error: ${message}` }],
37653
- isError: true
37654
- };
37655
- }
37656
- }
37715
+ wrapToolHandler(tool.handler)
37657
37716
  );
37658
37717
  }
37659
37718
  var transport = new StdioServerTransport();
37660
37719
  await server.connect(transport);
37661
37720
  var writesNote = isWritesAllowed() ? "writes ENABLED" : "read-only";
37662
37721
  console.error(`@yawlabs/postgres-mcp v${version2} ready (${allTools.length} tools, ${writesNote})`);
37722
+ var exiting = false;
37663
37723
  var cleanup = async () => {
37724
+ if (exiting) return;
37725
+ exiting = true;
37664
37726
  try {
37665
37727
  await shutdown();
37666
37728
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/postgres-mcp",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
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",