@yawlabs/postgres-mcp 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed
11
+ - `pg_advisor` `tables_without_primary_key` now includes partitioned-table
12
+ parents (`relkind='p'`) alongside plain heap tables. A partitioned table
13
+ with no PK is a real design-drift signal -- and the neighboring
14
+ `public_tables_without_rls` check already covered both relkinds, so the
15
+ inconsistency was an oversight. Partition children still inherit the
16
+ parent's PK as an `indisprimary` index, so they remain filtered out by
17
+ the existing `NOT EXISTS` clause.
18
+ - `pg_replication_status` now surfaces partial failures via a top-level
19
+ `_warnings` array instead of short-circuiting on the first sub-query
20
+ failure. Matches the convention already used by `pg_health`,
21
+ `pg_describe_table`, and `pg_advisor`. When the WAL position lookup
22
+ fails, `is_replica` is now `null` rather than `false` so a permission
23
+ error can't be mistaken for "this is a primary."
24
+ - `identSchema` (shared schema/table/column-name validator) now enforces
25
+ postgres's 63-byte `NAMEDATALEN` limit on byte length, not JS char
26
+ length. A multi-byte identifier like 32x `é` (64 UTF-8 bytes) used to
27
+ pass validation but postgres would silently truncate it; now it fails
28
+ at the call boundary with a clear message. Centralized in `params.ts`
29
+ so `schemas.ts`, `stats.ts`, `admin.ts`, and `explain.ts` share one
30
+ definition. `pg_explain.hypothetical_indexes` validates the same byte
31
+ limit per-piece on the `schema.table` form and per-column inside
32
+ `validateHypoIndex`, so direct handler calls that bypass Zod still
33
+ get the protection.
34
+ - `getSslConfig` now logs a one-shot stderr warning when
35
+ `POSTGRES_SSL_REJECT_UNAUTHORIZED` is set to an unrecognized value
36
+ (typo, empty string, ...). Previously the typo silently fell through
37
+ to pg's default, indistinguishable from "env var unset" -- a connection
38
+ with unintended TLS posture could land without any signal.
39
+
40
+ ### Changed
41
+ - `pg_top_queries` extension-presence and version probes consolidated
42
+ into a single catalog round-trip (was two). The actual stats query
43
+ remains a second round-trip since the column names are version-dynamic.
44
+
10
45
  ## [0.6.0] - 2026-05-14
11
46
 
12
47
  ### Added
package/dist/index.js CHANGED
@@ -36117,6 +36117,9 @@ function getSslConfig() {
36117
36117
  if (raw === void 0) return void 0;
36118
36118
  if (raw === "0" || raw === "false") return { rejectUnauthorized: false };
36119
36119
  if (raw === "1" || raw === "true") return { rejectUnauthorized: true };
36120
+ console.error(
36121
+ `[postgres-mcp] POSTGRES_SSL_REJECT_UNAUTHORIZED=${JSON.stringify(raw)} not recognized; expected "0", "false", "1", or "true". Deferring to the pg driver / connection-string default.`
36122
+ );
36120
36123
  return void 0;
36121
36124
  }
36122
36125
  function getPool() {
@@ -36346,6 +36349,14 @@ async function shutdown() {
36346
36349
  }
36347
36350
  }
36348
36351
 
36352
+ // src/tools/params.ts
36353
+ 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)])
36355
+ );
36356
+ var identSchema = external_exports.string().min(1).max(63).refine((v) => Buffer.byteLength(v, "utf8") <= 63, {
36357
+ message: "Identifier exceeds PostgreSQL's 63-byte NAMEDATALEN limit (multi-byte characters count as multiple bytes)."
36358
+ });
36359
+
36349
36360
  // src/tools/admin.ts
36350
36361
  var adminTools = [
36351
36362
  {
@@ -36445,8 +36456,8 @@ var adminTools = [
36445
36456
  openWorldHint: true
36446
36457
  },
36447
36458
  inputSchema: external_exports.object({
36448
- schema: external_exports.string().min(1).max(63).default("public").describe("Schema name (defaults to 'public')."),
36449
- table: external_exports.string().min(1).max(63).optional().describe("Table name. Omit to list privileges for all tables in the schema.")
36459
+ schema: identSchema.default("public").describe("Schema name (defaults to 'public')."),
36460
+ table: identSchema.optional().describe("Table name. Omit to list privileges for all tables in the schema.")
36450
36461
  }),
36451
36462
  handler: async (input) => {
36452
36463
  const { schema, table } = input;
@@ -36548,16 +36559,18 @@ var adminTools = [
36548
36559
  END AS wal_position`
36549
36560
  )
36550
36561
  ]);
36551
- if (!slotsRes.ok) return slotsRes;
36552
- if (!replicasRes.ok) return replicasRes;
36553
- if (!walRes.ok) return walRes;
36562
+ const warnings = [];
36563
+ if (!slotsRes.ok) warnings.push(`slots fetch failed: ${slotsRes.error}`);
36564
+ if (!replicasRes.ok) warnings.push(`replicas fetch failed: ${replicasRes.error}`);
36565
+ if (!walRes.ok) warnings.push(`wal_position fetch failed: ${walRes.error}`);
36554
36566
  return {
36555
36567
  ok: true,
36556
36568
  data: {
36557
- is_replica: walRes.data?.[0]?.is_in_recovery ?? false,
36558
- wal_position: walRes.data?.[0]?.wal_position ?? null,
36559
- slots: slotsRes.data ?? [],
36560
- replicas: replicasRes.data ?? []
36569
+ is_replica: walRes.ok ? walRes.data?.[0]?.is_in_recovery ?? false : null,
36570
+ wal_position: walRes.ok ? walRes.data?.[0]?.wal_position ?? null : null,
36571
+ slots: slotsRes.ok ? slotsRes.data ?? [] : [],
36572
+ replicas: replicasRes.ok ? replicasRes.data ?? [] : [],
36573
+ ...warnings.length > 0 ? { _warnings: warnings } : {}
36561
36574
  }
36562
36575
  };
36563
36576
  });
@@ -36575,7 +36588,7 @@ var adminTools = [
36575
36588
  },
36576
36589
  inputSchema: external_exports.object({
36577
36590
  seqExhaustionThreshold: external_exports.number().min(0).max(1).default(0.5).describe("Minimum used-fraction (last_value / max_value) to flag a sequence (default 0.5 = 50%)."),
36578
- rlsSchemas: external_exports.array(external_exports.string().min(1).max(63)).default(["public"]).describe("Schemas where RLS-missing should be flagged. Defaults to ['public']."),
36591
+ rlsSchemas: external_exports.array(identSchema).default(["public"]).describe("Schemas where RLS-missing should be flagged. Defaults to ['public']."),
36579
36592
  limit: external_exports.number().int().min(1).max(500).default(50).describe("Max rows per category (default 50).")
36580
36593
  }),
36581
36594
  handler: async (input) => {
@@ -36603,15 +36616,22 @@ var adminTools = [
36603
36616
  [seqExhaustionThreshold, limit]
36604
36617
  ),
36605
36618
  run(
36606
- // Declarative-partition children inherit the parent's primary key
36607
- // as an indisprimary index, so the NOT EXISTS clause already
36608
- // excludes them. Nothing extra needed for partitioned schemas.
36619
+ // Includes partitioned parents (relkind='p') alongside plain heap
36620
+ // tables ('r'). A partitioned table with no PK is a real design-
36621
+ // drift signal -- if a PK exists on a partitioned table it must
36622
+ // include the partition key columns, but having no PK at all is
36623
+ // legal and usually unintended. Matches the relkind filter used
36624
+ // by public_tables_without_rls below.
36625
+ //
36626
+ // Partition children (relkind='r') inherit the parent's PK as an
36627
+ // indisprimary index on the child, so the NOT EXISTS clause keeps
36628
+ // already filtering them out.
36609
36629
  `SELECT
36610
36630
  n.nspname AS schema,
36611
36631
  c.relname AS "table"
36612
36632
  FROM pg_catalog.pg_class c
36613
36633
  JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
36614
- WHERE c.relkind = 'r'
36634
+ WHERE c.relkind IN ('r', 'p')
36615
36635
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
36616
36636
  AND n.nspname NOT LIKE 'pg_%'
36617
36637
  AND NOT EXISTS (
@@ -36663,7 +36683,7 @@ var adminTools = [
36663
36683
  openWorldHint: true
36664
36684
  },
36665
36685
  inputSchema: external_exports.object({
36666
- schema: external_exports.string().min(1).max(63).optional().describe("Limit to one schema. If omitted, all user schemas are included."),
36686
+ schema: identSchema.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
36667
36687
  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%."),
36668
36688
  limit: external_exports.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
36669
36689
  }),
@@ -36699,16 +36719,15 @@ var adminTools = [
36699
36719
  }
36700
36720
  ];
36701
36721
 
36702
- // src/tools/params.ts
36703
- var paramValue = external_exports.lazy(
36704
- () => 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)])
36705
- );
36706
-
36707
36722
  // src/tools/explain.ts
36708
36723
  var indexAccessMethod = external_exports.enum(["btree", "hash", "gin", "gist", "brin", "spgist"]);
36709
36724
  var hypotheticalIndex = external_exports.object({
36725
+ // `table` is `schema.table` or `table`. The 127-char ceiling is a generous
36726
+ // upper bound on the combined form -- the actual NAMEDATALEN (63-byte)
36727
+ // limit on each piece after the split is enforced in validateHypoIndex,
36728
+ // since splitting and per-piece byte-checking is awkward in Zod.
36710
36729
  table: external_exports.string().min(1).max(127).describe("Target table. Use `schema.table` (e.g. `public.users`) or just `table` for the search_path."),
36711
- columns: external_exports.array(external_exports.string().min(1).max(63)).min(1).describe("Column names in index order. Quoted identifiers are not supported here -- pass plain names."),
36730
+ columns: external_exports.array(identSchema).min(1).describe("Column names in index order. Quoted identifiers are not supported here -- pass plain names."),
36712
36731
  using: indexAccessMethod.default("btree").describe("Index access method. btree is the right answer for almost every query.")
36713
36732
  });
36714
36733
  function quoteIdent(name) {
@@ -36722,11 +36741,17 @@ function validateHypoIndex(idx) {
36722
36741
  if (piece.includes('"')) {
36723
36742
  return `Hypothetical index table ${JSON.stringify(idx.table)} contains a double-quote; pass plain identifier names without pre-quoting.`;
36724
36743
  }
36744
+ if (Buffer.byteLength(piece, "utf8") > 63) {
36745
+ return `Hypothetical index table piece ${JSON.stringify(piece)} exceeds PostgreSQL's 63-byte NAMEDATALEN limit (multi-byte characters count as multiple bytes).`;
36746
+ }
36725
36747
  }
36726
36748
  for (const col of idx.columns) {
36727
36749
  if (col.includes('"')) {
36728
36750
  return `Hypothetical index column ${JSON.stringify(col)} contains a double-quote; pass plain identifier names without pre-quoting.`;
36729
36751
  }
36752
+ if (Buffer.byteLength(col, "utf8") > 63) {
36753
+ return `Hypothetical index column ${JSON.stringify(col)} exceeds PostgreSQL's 63-byte NAMEDATALEN limit (multi-byte characters count as multiple bytes).`;
36754
+ }
36730
36755
  }
36731
36756
  return null;
36732
36757
  }
@@ -36949,7 +36974,6 @@ var queryTools = [
36949
36974
  ];
36950
36975
 
36951
36976
  // src/tools/schemas.ts
36952
- var identSchema = external_exports.string().min(1).max(63);
36953
36977
  var schemaTools = [
36954
36978
  {
36955
36979
  name: "pg_list_schemas",
@@ -37365,7 +37389,6 @@ var schemaTools = [
37365
37389
  ];
37366
37390
 
37367
37391
  // src/tools/stats.ts
37368
- var identSchema2 = external_exports.string().min(1).max(63);
37369
37392
  var statsTools = [
37370
37393
  {
37371
37394
  name: "pg_top_queries",
@@ -37383,22 +37406,17 @@ var statsTools = [
37383
37406
  }),
37384
37407
  handler: async (input) => {
37385
37408
  const { orderBy, limit } = input;
37386
- const check2 = await runInternal(
37387
- `SELECT EXISTS (
37388
- SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'
37389
- ) AS installed`
37409
+ const versionRes = await runInternal(
37410
+ `SELECT extversion AS version FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'`
37390
37411
  );
37391
- if (!check2.ok) return check2;
37392
- if (!check2.data?.[0]?.installed) {
37412
+ if (!versionRes.ok) return versionRes;
37413
+ if (!versionRes.data || versionRes.data.length === 0) {
37393
37414
  return {
37394
37415
  ok: false,
37395
37416
  error: "pg_stat_statements extension is not installed. Install it with `CREATE EXTENSION pg_stat_statements;` (may require superuser) and add `pg_stat_statements` to `shared_preload_libraries` in postgresql.conf, then restart."
37396
37417
  };
37397
37418
  }
37398
- const versionRes = await runInternal(
37399
- `SELECT extversion AS version FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'`
37400
- );
37401
- const extVersion = versionRes.ok ? versionRes.data?.[0]?.version ?? "0" : "0";
37419
+ const extVersion = versionRes.data[0]?.version ?? "0";
37402
37420
  const useExecSuffix = compareVersions(extVersion, "1.8") >= 0;
37403
37421
  const totalCol = useExecSuffix ? "total_exec_time" : "total_time";
37404
37422
  const meanCol = useExecSuffix ? "mean_exec_time" : "mean_time";
@@ -37441,7 +37459,7 @@ var statsTools = [
37441
37459
  openWorldHint: true
37442
37460
  },
37443
37461
  inputSchema: external_exports.object({
37444
- schema: identSchema2.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
37462
+ schema: identSchema.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
37445
37463
  minSize: external_exports.number().int().min(0).default(1e3).describe("Minimum live tuple count to include (default 1000, filters out tiny/empty tables)."),
37446
37464
  limit: external_exports.number().int().min(1).max(100).default(20).describe("Max rows to return (default 20).")
37447
37465
  }),
@@ -37482,7 +37500,7 @@ var statsTools = [
37482
37500
  openWorldHint: true
37483
37501
  },
37484
37502
  inputSchema: external_exports.object({
37485
- schema: identSchema2.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
37503
+ schema: identSchema.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
37486
37504
  maxScans: external_exports.number().int().min(0).default(10).describe("Include indexes with scan count <= this (default 10). Use 0 for 'never scanned'."),
37487
37505
  limit: external_exports.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
37488
37506
  }),
@@ -37529,7 +37547,7 @@ function compareVersions(a, b) {
37529
37547
  }
37530
37548
 
37531
37549
  // src/index.ts
37532
- var version2 = true ? "0.6.0" : (await null).createRequire(import.meta.url)("../package.json").version;
37550
+ var version2 = true ? "0.6.1" : (await null).createRequire(import.meta.url)("../package.json").version;
37533
37551
  var subcommand = process.argv[2];
37534
37552
  if (subcommand === "version" || subcommand === "--version") {
37535
37553
  console.log(version2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/postgres-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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>",