@yawlabs/postgres-mcp 0.4.0 → 0.5.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/index.js +272 -216
  3. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-05-04
11
+
12
+ ### Changed
13
+ - **BREAKING:** `engines.node` raised from `>=18` to `>=20`. The CI matrix
14
+ has been on `[20, 22]` since 0.4.1 and the integration + release jobs
15
+ ran exclusively on Node 20+, so Node 18 was effectively unsupported in
16
+ practice; this release makes the declared support match. esbuild
17
+ `target` and `release.sh` prerequisite comment updated to match.
18
+
19
+ ## [0.4.1] - 2026-05-04
20
+
21
+ ### Changed
22
+ - Multi-query handlers (`pg_describe_table`, `pg_health`, `pg_advisor`,
23
+ `pg_replication_status`) now share a single connection across their
24
+ internal catalog fan-out via a new `withSharedClient` helper, so one
25
+ tool call's 3-9 query fan-out can no longer saturate the pool (default
26
+ max 5) and starve concurrent calls. Previously, a single
27
+ `pg_describe_table` issued 9 parallel `Promise.all` queries against the
28
+ pool; under concurrent load this could block other tool calls until
29
+ the describe drained.
30
+ - `pg_top_queries` now returns `calls` and `rows` as text strings,
31
+ matching the bigint serialization of `pg_seq_scan_tables`,
32
+ `pg_unused_indexes`, and `pg_table_bloat`. Timing fields stay as JSON
33
+ numbers since they are inherently fractional milliseconds.
34
+
35
+ ### Fixed
36
+ - `runUserQueryBounded` now distinguishes "DECLARE failed" (re-running
37
+ on the direct-exec path is safe -- the user SQL never executed) from
38
+ "FETCH/CLOSE/RELEASE failed" (re-running could double-execute side
39
+ effects). Previously, a transient FETCH-time failure on a
40
+ RETURNING-DML statement would silently re-run the mutation.
41
+ - `pg_top_queries` `orderBy: "calls"` now sorts numerically rather than
42
+ lexically. The 0.4.1 change to return `calls` as `text` shadowed the
43
+ source bigint column with a text output alias in the ORDER BY, so
44
+ "9" beat "10". The fix qualifies the ORDER BY expression with the
45
+ source table. Caught in the post-implementation review before tagging.
46
+ - `pg_describe_table` "not found" error message now escapes user-supplied
47
+ schema/table names via `JSON.stringify`, so a name containing `"` no
48
+ longer renders as broken-looking nested quotes.
49
+ - `pg_explain` `hypothetical_indexes` now pre-flight rejects pre-quoted
50
+ identifiers (`"odd.name"`, `weird"col`) with a clear validation error
51
+ before opening a database connection, instead of producing a confusing
52
+ planner error after the fact.
53
+
54
+ ### Added
55
+ - New CI release plumbing mirroring `@yawlabs/tailscale-mcp`:
56
+ `.github/workflows/ci.yml` (lint + build + test on push/PR across
57
+ Node 18/20/22), `.github/workflows/integration.yml` (PG17 + PG18
58
+ service-container matrix, scheduled nightly + on-demand), and
59
+ `.github/workflows/release.yml` (tag-pushes-trigger-publish gated
60
+ on integration). `npm publish` now runs in CI with `--provenance`
61
+ via the org-level `NPM_TOKEN`; `release.sh` retains a local-dev
62
+ path that runs the WSL integration matrix as a pre-flight when
63
+ cutting a release from a workstation.
64
+ - New regression tests:
65
+ - `compareVersions` unit tests covering pre-release tags,
66
+ missing/longer segments, and the actual `1.8` boundary used by
67
+ `pg_top_queries`.
68
+ - Integration guard that partition children inheriting a parent's
69
+ primary key are correctly excluded from `pg_advisor`'s
70
+ `tables_without_primary_key` list.
71
+ - Integration coverage for the cursor-fallback path on both DDL
72
+ (`CREATE TABLE`) and DML-without-RETURNING (`INSERT`).
73
+
10
74
  ## [0.4.0] - 2026-04-25
11
75
 
12
76
  ### Security
package/dist/index.js CHANGED
@@ -35340,12 +35340,14 @@ function formatPgError(err) {
35340
35340
  }
35341
35341
  async function runUserQueryBounded(client, sql, params, maxRows) {
35342
35342
  await client.query("SAVEPOINT __pgmcp_sp");
35343
+ let declareSucceeded = false;
35343
35344
  try {
35344
35345
  await client.query({
35345
35346
  text: `DECLARE __pgmcp_cur NO SCROLL CURSOR FOR ${sql}`,
35346
35347
  values: params,
35347
35348
  queryMode: "extended"
35348
35349
  });
35350
+ declareSucceeded = true;
35349
35351
  const fetched = await client.query(`FETCH ${maxRows + 1} FROM __pgmcp_cur`);
35350
35352
  try {
35351
35353
  await client.query("CLOSE __pgmcp_cur");
@@ -35353,7 +35355,10 @@ async function runUserQueryBounded(client, sql, params, maxRows) {
35353
35355
  }
35354
35356
  await client.query("RELEASE SAVEPOINT __pgmcp_sp");
35355
35357
  return fetched;
35356
- } catch {
35358
+ } catch (err) {
35359
+ if (declareSucceeded) {
35360
+ throw err;
35361
+ }
35357
35362
  await client.query("ROLLBACK TO SAVEPOINT __pgmcp_sp");
35358
35363
  await client.query("RELEASE SAVEPOINT __pgmcp_sp");
35359
35364
  return client.query({
@@ -35468,6 +35473,22 @@ async function runInternal(sql, params = []) {
35468
35473
  return { ok: false, error: formatPgError(err) };
35469
35474
  }
35470
35475
  }
35476
+ async function withSharedClient(fn) {
35477
+ const client = await getPool().connect();
35478
+ try {
35479
+ const runOnClient = async (sql, params = []) => {
35480
+ try {
35481
+ const result = await client.query(sql, params);
35482
+ return { ok: true, data: result.rows };
35483
+ } catch (err) {
35484
+ return { ok: false, error: formatPgError(err) };
35485
+ }
35486
+ };
35487
+ return await fn(runOnClient);
35488
+ } finally {
35489
+ client.release();
35490
+ }
35491
+ }
35471
35492
  async function shutdown() {
35472
35493
  typeNameCache = null;
35473
35494
  if (!pool) return;
@@ -35652,49 +35673,51 @@ var adminTools = [
35652
35673
  },
35653
35674
  inputSchema: external_exports3.object({}),
35654
35675
  handler: async () => {
35655
- const [slotsRes, replicasRes, walRes] = await Promise.all([
35656
- runInternal(
35657
- `SELECT
35658
- slot_name, slot_type, active,
35659
- restart_lsn::text AS restart_lsn,
35660
- confirmed_flush_lsn::text AS confirmed_flush_lsn,
35661
- wal_status, database, plugin
35662
- FROM pg_catalog.pg_replication_slots
35663
- ORDER BY slot_name`
35664
- ),
35665
- runInternal(
35666
- `SELECT
35667
- application_name,
35668
- client_addr::text AS client_addr,
35669
- state,
35670
- sync_state,
35671
- EXTRACT(EPOCH FROM write_lag)::numeric(10, 2)::float8 AS write_lag_seconds,
35672
- EXTRACT(EPOCH FROM flush_lag)::numeric(10, 2)::float8 AS flush_lag_seconds,
35673
- EXTRACT(EPOCH FROM replay_lag)::numeric(10, 2)::float8 AS replay_lag_seconds
35674
- FROM pg_catalog.pg_stat_replication
35675
- ORDER BY application_name`
35676
- ),
35677
- runInternal(
35678
- `SELECT
35679
- pg_is_in_recovery() AS is_in_recovery,
35680
- CASE
35681
- WHEN pg_is_in_recovery() THEN pg_last_wal_receive_lsn()::text
35682
- ELSE pg_current_wal_lsn()::text
35683
- END AS wal_position`
35684
- )
35685
- ]);
35686
- if (!slotsRes.ok) return slotsRes;
35687
- if (!replicasRes.ok) return replicasRes;
35688
- if (!walRes.ok) return walRes;
35689
- return {
35690
- ok: true,
35691
- data: {
35692
- is_replica: walRes.data?.[0]?.is_in_recovery ?? false,
35693
- wal_position: walRes.data?.[0]?.wal_position ?? null,
35694
- slots: slotsRes.data ?? [],
35695
- replicas: replicasRes.data ?? []
35696
- }
35697
- };
35676
+ return withSharedClient(async (run) => {
35677
+ const [slotsRes, replicasRes, walRes] = await Promise.all([
35678
+ run(
35679
+ `SELECT
35680
+ slot_name, slot_type, active,
35681
+ restart_lsn::text AS restart_lsn,
35682
+ confirmed_flush_lsn::text AS confirmed_flush_lsn,
35683
+ wal_status, database, plugin
35684
+ FROM pg_catalog.pg_replication_slots
35685
+ ORDER BY slot_name`
35686
+ ),
35687
+ run(
35688
+ `SELECT
35689
+ application_name,
35690
+ client_addr::text AS client_addr,
35691
+ state,
35692
+ sync_state,
35693
+ EXTRACT(EPOCH FROM write_lag)::numeric(10, 2)::float8 AS write_lag_seconds,
35694
+ EXTRACT(EPOCH FROM flush_lag)::numeric(10, 2)::float8 AS flush_lag_seconds,
35695
+ EXTRACT(EPOCH FROM replay_lag)::numeric(10, 2)::float8 AS replay_lag_seconds
35696
+ FROM pg_catalog.pg_stat_replication
35697
+ ORDER BY application_name`
35698
+ ),
35699
+ run(
35700
+ `SELECT
35701
+ pg_is_in_recovery() AS is_in_recovery,
35702
+ CASE
35703
+ WHEN pg_is_in_recovery() THEN pg_last_wal_receive_lsn()::text
35704
+ ELSE pg_current_wal_lsn()::text
35705
+ END AS wal_position`
35706
+ )
35707
+ ]);
35708
+ if (!slotsRes.ok) return slotsRes;
35709
+ if (!replicasRes.ok) return replicasRes;
35710
+ if (!walRes.ok) return walRes;
35711
+ return {
35712
+ ok: true,
35713
+ data: {
35714
+ is_replica: walRes.data?.[0]?.is_in_recovery ?? false,
35715
+ wal_position: walRes.data?.[0]?.wal_position ?? null,
35716
+ slots: slotsRes.data ?? [],
35717
+ replicas: replicasRes.data ?? []
35718
+ }
35719
+ };
35720
+ });
35698
35721
  }
35699
35722
  },
35700
35723
  {
@@ -35714,68 +35737,73 @@ var adminTools = [
35714
35737
  }),
35715
35738
  handler: async (input) => {
35716
35739
  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
- };
35740
+ return withSharedClient(async (run) => {
35741
+ const [seqRes, noPkRes, rlsRes] = await Promise.all([
35742
+ run(
35743
+ // pg_sequences was added in PG10. last_value can be NULL on a never-
35744
+ // touched sequence; we filter those out (nothing to report yet).
35745
+ `SELECT
35746
+ schemaname AS schema,
35747
+ sequencename AS sequence,
35748
+ last_value::text AS last_value,
35749
+ max_value::text AS max_value,
35750
+ (last_value::float8 / NULLIF(max_value::float8, 0))::numeric(6, 4)::float8 AS pct_used
35751
+ FROM pg_catalog.pg_sequences
35752
+ WHERE last_value IS NOT NULL
35753
+ AND max_value > 0
35754
+ AND (last_value::float8 / max_value::float8) >= $1
35755
+ ORDER BY pct_used DESC NULLS LAST
35756
+ LIMIT $2`,
35757
+ [seqExhaustionThreshold, limit]
35758
+ ),
35759
+ run(
35760
+ // Declarative-partition children inherit the parent's primary key
35761
+ // as an indisprimary index, so the NOT EXISTS clause already
35762
+ // excludes them. Nothing extra needed for partitioned schemas.
35763
+ `SELECT
35764
+ n.nspname AS schema,
35765
+ c.relname AS "table"
35766
+ FROM pg_catalog.pg_class c
35767
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
35768
+ WHERE c.relkind = 'r'
35769
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
35770
+ AND n.nspname NOT LIKE 'pg_%'
35771
+ AND NOT EXISTS (
35772
+ SELECT 1 FROM pg_catalog.pg_index i
35773
+ WHERE i.indrelid = c.oid AND i.indisprimary
35774
+ )
35775
+ ORDER BY n.nspname, c.relname
35776
+ LIMIT $1`,
35777
+ [limit]
35778
+ ),
35779
+ run(
35780
+ `SELECT
35781
+ n.nspname AS schema,
35782
+ c.relname AS "table"
35783
+ FROM pg_catalog.pg_class c
35784
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
35785
+ WHERE c.relkind IN ('r', 'p')
35786
+ AND n.nspname = ANY($1)
35787
+ AND NOT c.relrowsecurity
35788
+ ORDER BY n.nspname, c.relname
35789
+ LIMIT $2`,
35790
+ [rlsSchemas, limit]
35791
+ )
35792
+ ]);
35793
+ const warnings = [];
35794
+ if (!seqRes.ok) warnings.push(`sequence_exhaustion fetch failed: ${seqRes.error}`);
35795
+ if (!noPkRes.ok) warnings.push(`tables_without_primary_key fetch failed: ${noPkRes.error}`);
35796
+ if (!rlsRes.ok) warnings.push(`public_tables_without_rls fetch failed: ${rlsRes.error}`);
35797
+ return {
35798
+ ok: true,
35799
+ data: {
35800
+ sequence_exhaustion: seqRes.ok ? seqRes.data : [],
35801
+ tables_without_primary_key: noPkRes.ok ? noPkRes.data : [],
35802
+ public_tables_without_rls: rlsRes.ok ? rlsRes.data : [],
35803
+ ...warnings.length > 0 ? { _warnings: warnings } : {}
35804
+ }
35805
+ };
35806
+ });
35779
35807
  }
35780
35808
  },
35781
35809
  {
@@ -35843,6 +35871,19 @@ function quoteIdent(name) {
35843
35871
  function quoteQualifiedTable(name) {
35844
35872
  return name.split(".").map((p) => quoteIdent(p)).join(".");
35845
35873
  }
35874
+ function validateHypoIndex(idx) {
35875
+ for (const piece of idx.table.split(".")) {
35876
+ if (piece.includes('"')) {
35877
+ return `Hypothetical index table ${JSON.stringify(idx.table)} contains a double-quote; pass plain identifier names without pre-quoting.`;
35878
+ }
35879
+ }
35880
+ for (const col of idx.columns) {
35881
+ if (col.includes('"')) {
35882
+ return `Hypothetical index column ${JSON.stringify(col)} contains a double-quote; pass plain identifier names without pre-quoting.`;
35883
+ }
35884
+ }
35885
+ return null;
35886
+ }
35846
35887
  function buildHypopgHooks(indexes) {
35847
35888
  return {
35848
35889
  setup: async (client) => {
@@ -35897,6 +35938,10 @@ var explainTools = [
35897
35938
  if (format === "json") flags.push("FORMAT JSON");
35898
35939
  const explainSql = flags.length > 0 ? `EXPLAIN (${flags.join(", ")}) ${sql}` : `EXPLAIN ${sql}`;
35899
35940
  const hypoIndexes = hypothetical_indexes ?? [];
35941
+ for (const idx of hypoIndexes) {
35942
+ const err = validateHypoIndex(idx);
35943
+ if (err) return { ok: false, error: err };
35944
+ }
35900
35945
  const hooks = hypoIndexes.length > 0 ? buildHypopgHooks(hypoIndexes) : {};
35901
35946
  if (hypoIndexes.length > 0) {
35902
35947
  const check2 = await runInternal(
@@ -35943,67 +35988,69 @@ var healthTools = [
35943
35988
  }),
35944
35989
  handler: async (input) => {
35945
35990
  const { activeQueryLimit } = input;
35946
- const [versionRes, sizeRes, connsRes, activeRes, tableCountRes] = await Promise.all([
35947
- runInternal(`SELECT version() AS version`),
35948
- runInternal(
35949
- `SELECT
35950
- current_database() AS database,
35951
- pg_size_pretty(pg_database_size(current_database())) AS size_pretty,
35952
- pg_database_size(current_database())::text AS size_bytes`
35953
- ),
35954
- runInternal(
35955
- `SELECT
35956
- count(*)::text AS total,
35957
- count(*) FILTER (WHERE state = 'active')::text AS active,
35958
- count(*) FILTER (WHERE state = 'idle')::text AS idle,
35959
- count(*) FILTER (WHERE state = 'idle in transaction')::text AS idle_in_transaction
35960
- FROM pg_stat_activity
35961
- WHERE datname = current_database()`
35962
- ),
35963
- runInternal(
35964
- `SELECT
35965
- pid,
35966
- state,
35967
- EXTRACT(EPOCH FROM (now() - query_start))::numeric(10, 2)::float8 AS duration_seconds,
35968
- query,
35969
- application_name
35970
- FROM pg_stat_activity
35971
- WHERE datname = current_database()
35972
- AND state IS NOT NULL
35973
- AND state <> 'idle'
35974
- AND pid <> pg_backend_pid()
35975
- ORDER BY query_start ASC NULLS LAST
35976
- LIMIT $1`,
35977
- [activeQueryLimit]
35978
- ),
35979
- runInternal(
35980
- `SELECT count(*)::text AS count
35981
- FROM pg_catalog.pg_class c
35982
- JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
35983
- WHERE c.relkind IN ('r', 'p')
35984
- AND n.nspname NOT IN ('pg_catalog', 'information_schema')
35985
- AND n.nspname NOT LIKE 'pg_toast%'
35986
- AND n.nspname NOT LIKE 'pg_temp_%'`
35987
- )
35988
- ]);
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}`);
35995
- return {
35996
- ok: true,
35997
- data: {
35998
- connected: true,
35999
- version: versionRes.data?.[0]?.version,
36000
- database: sizeRes.ok ? sizeRes.data?.[0] : null,
36001
- connections: connsRes.ok ? connsRes.data?.[0] : null,
36002
- active_queries: activeRes.ok ? activeRes.data : [],
36003
- table_count: tableCountRes.ok ? tableCountRes.data?.[0]?.count : null,
36004
- ...warnings.length > 0 ? { _warnings: warnings } : {}
36005
- }
36006
- };
35991
+ return withSharedClient(async (run) => {
35992
+ const [versionRes, sizeRes, connsRes, activeRes, tableCountRes] = await Promise.all([
35993
+ run(`SELECT version() AS version`),
35994
+ run(
35995
+ `SELECT
35996
+ current_database() AS database,
35997
+ pg_size_pretty(pg_database_size(current_database())) AS size_pretty,
35998
+ pg_database_size(current_database())::text AS size_bytes`
35999
+ ),
36000
+ run(
36001
+ `SELECT
36002
+ count(*)::text AS total,
36003
+ count(*) FILTER (WHERE state = 'active')::text AS active,
36004
+ count(*) FILTER (WHERE state = 'idle')::text AS idle,
36005
+ count(*) FILTER (WHERE state = 'idle in transaction')::text AS idle_in_transaction
36006
+ FROM pg_stat_activity
36007
+ WHERE datname = current_database()`
36008
+ ),
36009
+ run(
36010
+ `SELECT
36011
+ pid,
36012
+ state,
36013
+ EXTRACT(EPOCH FROM (now() - query_start))::numeric(10, 2)::float8 AS duration_seconds,
36014
+ query,
36015
+ application_name
36016
+ FROM pg_stat_activity
36017
+ WHERE datname = current_database()
36018
+ AND state IS NOT NULL
36019
+ AND state <> 'idle'
36020
+ AND pid <> pg_backend_pid()
36021
+ ORDER BY query_start ASC NULLS LAST
36022
+ LIMIT $1`,
36023
+ [activeQueryLimit]
36024
+ ),
36025
+ run(
36026
+ `SELECT count(*)::text AS count
36027
+ FROM pg_catalog.pg_class c
36028
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
36029
+ WHERE c.relkind IN ('r', 'p')
36030
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
36031
+ AND n.nspname NOT LIKE 'pg_toast%'
36032
+ AND n.nspname NOT LIKE 'pg_temp_%'`
36033
+ )
36034
+ ]);
36035
+ if (!versionRes.ok) return versionRes;
36036
+ const warnings = [];
36037
+ if (!sizeRes.ok) warnings.push(`database fetch failed: ${sizeRes.error}`);
36038
+ if (!connsRes.ok) warnings.push(`connections fetch failed: ${connsRes.error}`);
36039
+ if (!activeRes.ok) warnings.push(`active_queries fetch failed: ${activeRes.error}`);
36040
+ if (!tableCountRes.ok) warnings.push(`table_count fetch failed: ${tableCountRes.error}`);
36041
+ return {
36042
+ ok: true,
36043
+ data: {
36044
+ connected: true,
36045
+ version: versionRes.data?.[0]?.version,
36046
+ database: sizeRes.ok ? sizeRes.data?.[0] : null,
36047
+ connections: connsRes.ok ? connsRes.data?.[0] : null,
36048
+ active_queries: activeRes.ok ? activeRes.data : [],
36049
+ table_count: tableCountRes.ok ? tableCountRes.data?.[0]?.count : null,
36050
+ ...warnings.length > 0 ? { _warnings: warnings } : {}
36051
+ }
36052
+ };
36053
+ });
36007
36054
  }
36008
36055
  }
36009
36056
  ];
@@ -36265,48 +36312,53 @@ var schemaTools = [
36265
36312
  AND child.relispartition
36266
36313
  ORDER BY childn.nspname, child.relname
36267
36314
  `;
36268
- const [kindRes, cols, pk, fks, idxs, constraints, referencedBy, partitionParent, partitionChildren] = await Promise.all([
36269
- runInternal(kindQuery, [schema, table]),
36270
- runInternal(columnsQuery, [schema, table]),
36271
- runInternal(primaryKeyQuery, [schema, table]),
36272
- runInternal(foreignKeysQuery, [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])
36278
- ]);
36279
- if (!cols.ok) return cols;
36280
- if (!cols.data || cols.data.length === 0) {
36281
- return { ok: false, error: `Table "${schema}"."${table}" not found.` };
36282
- }
36283
- const kind = kindRes.ok ? kindRes.data?.[0]?.kind ?? "table" : "table";
36284
- const warnings = [];
36285
- if (!pk.ok) warnings.push(`primary_key fetch failed: ${pk.error}`);
36286
- if (!fks.ok) warnings.push(`foreign_keys fetch failed: ${fks.error}`);
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;
36293
- return {
36294
- ok: true,
36295
- data: {
36296
- schema,
36297
- table,
36298
- kind,
36299
- columns: cols.data,
36300
- primary_key: pk.ok ? (pk.data ?? []).map((r) => r.column_name) : [],
36301
- foreign_keys: fks.ok ? fks.data : [],
36302
- referenced_by: referencedBy.ok ? referencedBy.data : [],
36303
- constraints: constraints.ok ? constraints.data : [],
36304
- indexes: idxs.ok ? idxs.data : [],
36305
- ...parentRow ? { partition_of: parentRow } : {},
36306
- ...partitionChildren.ok && (partitionChildren.data ?? []).length > 0 ? { partitions: partitionChildren.data } : {},
36307
- ...warnings.length > 0 ? { _warnings: warnings } : {}
36315
+ return withSharedClient(async (run) => {
36316
+ const [kindRes, cols, pk, fks, idxs, constraints, referencedBy, partitionParent, partitionChildren] = await Promise.all([
36317
+ run(kindQuery, [schema, table]),
36318
+ run(columnsQuery, [schema, table]),
36319
+ run(primaryKeyQuery, [schema, table]),
36320
+ run(foreignKeysQuery, [schema, table]),
36321
+ run(indexesQuery, [schema, table]),
36322
+ run(constraintsQuery, [schema, table]),
36323
+ run(referencedByQuery, [schema, table]),
36324
+ run(partitionParentQuery, [schema, table]),
36325
+ run(partitionChildrenQuery, [schema, table])
36326
+ ]);
36327
+ if (!cols.ok) return cols;
36328
+ if (!cols.data || cols.data.length === 0) {
36329
+ return {
36330
+ ok: false,
36331
+ error: `Table ${JSON.stringify(schema)}.${JSON.stringify(table)} not found.`
36332
+ };
36308
36333
  }
36309
- };
36334
+ const kind = kindRes.ok ? kindRes.data?.[0]?.kind ?? "table" : "table";
36335
+ const warnings = [];
36336
+ if (!pk.ok) warnings.push(`primary_key fetch failed: ${pk.error}`);
36337
+ if (!fks.ok) warnings.push(`foreign_keys fetch failed: ${fks.error}`);
36338
+ if (!idxs.ok) warnings.push(`indexes fetch failed: ${idxs.error}`);
36339
+ if (!constraints.ok) warnings.push(`constraints fetch failed: ${constraints.error}`);
36340
+ if (!referencedBy.ok) warnings.push(`referenced_by fetch failed: ${referencedBy.error}`);
36341
+ if (!partitionParent.ok) warnings.push(`partition_of fetch failed: ${partitionParent.error}`);
36342
+ if (!partitionChildren.ok) warnings.push(`partitions fetch failed: ${partitionChildren.error}`);
36343
+ const parentRow = partitionParent.ok ? partitionParent.data?.[0] : void 0;
36344
+ return {
36345
+ ok: true,
36346
+ data: {
36347
+ schema,
36348
+ table,
36349
+ kind,
36350
+ columns: cols.data,
36351
+ primary_key: pk.ok ? (pk.data ?? []).map((r) => r.column_name) : [],
36352
+ foreign_keys: fks.ok ? fks.data : [],
36353
+ referenced_by: referencedBy.ok ? referencedBy.data : [],
36354
+ constraints: constraints.ok ? constraints.data : [],
36355
+ indexes: idxs.ok ? idxs.data : [],
36356
+ ...parentRow ? { partition_of: parentRow } : {},
36357
+ ...partitionChildren.ok && (partitionChildren.data ?? []).length > 0 ? { partitions: partitionChildren.data } : {},
36358
+ ...warnings.length > 0 ? { _warnings: warnings } : {}
36359
+ }
36360
+ };
36361
+ });
36310
36362
  }
36311
36363
  },
36312
36364
  {
@@ -36485,19 +36537,20 @@ var statsTools = [
36485
36537
  const meanCol = useExecSuffix ? "mean_exec_time" : "mean_time";
36486
36538
  const minCol = useExecSuffix ? "min_exec_time" : "min_time";
36487
36539
  const maxCol = useExecSuffix ? "max_exec_time" : "max_time";
36488
- const orderCol = orderBy === "total_time" ? totalCol : orderBy === "mean_time" ? meanCol : "calls";
36540
+ const orderCol = orderBy === "total_time" ? totalCol : orderBy === "mean_time" ? meanCol : "pg_stat_statements.calls";
36489
36541
  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.
36542
+ // bigint counters (calls, rows) come back as `.text` for lossless
36543
+ // serialization, matching pg_seq_scan_tables / pg_unused_indexes /
36544
+ // pg_table_bloat. Timing fields stay as float8 because they are
36545
+ // inherently fractional milliseconds.
36493
36546
  `SELECT
36494
36547
  query,
36495
- calls::float8 AS calls,
36548
+ calls::text AS calls,
36496
36549
  ${totalCol}::numeric(18, 2)::float8 AS total_time_ms,
36497
36550
  ${meanCol}::numeric(18, 2)::float8 AS mean_time_ms,
36498
36551
  ${minCol}::numeric(18, 2)::float8 AS min_time_ms,
36499
36552
  ${maxCol}::numeric(18, 2)::float8 AS max_time_ms,
36500
- rows::float8 AS rows,
36553
+ rows::text AS rows,
36501
36554
  CASE
36502
36555
  WHEN (shared_blks_hit + shared_blks_read) > 0
36503
36556
  THEN (shared_blks_hit::float8 / (shared_blks_hit + shared_blks_read) * 100)::numeric(5, 2)::float8
@@ -36594,7 +36647,10 @@ var statsTools = [
36594
36647
  }
36595
36648
  ];
36596
36649
  function compareVersions(a, b) {
36597
- const parse3 = (v) => v.split(".").map((n) => Number.parseInt(n, 10) || 0);
36650
+ const parse3 = (v) => v.split(".").map((seg) => {
36651
+ const m = seg.match(/^\d+/);
36652
+ return m ? Number.parseInt(m[0], 10) : 0;
36653
+ });
36598
36654
  const aa = parse3(a);
36599
36655
  const bb = parse3(b);
36600
36656
  const len = Math.max(aa.length, bb.length);
@@ -36606,7 +36662,7 @@ function compareVersions(a, b) {
36606
36662
  }
36607
36663
 
36608
36664
  // src/index.ts
36609
- var version2 = true ? "0.4.0" : (await null).createRequire(import.meta.url)("../package.json").version;
36665
+ var version2 = true ? "0.5.0" : (await null).createRequire(import.meta.url)("../package.json").version;
36610
36666
  var subcommand = process.argv[2];
36611
36667
  if (subcommand === "version" || subcommand === "--version") {
36612
36668
  console.log(version2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/postgres-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.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>",
@@ -57,6 +57,6 @@
57
57
  "zod": "^4.3.6"
58
58
  },
59
59
  "engines": {
60
- "node": ">=18"
60
+ "node": ">=20"
61
61
  }
62
62
  }