@yawlabs/postgres-mcp 0.7.0 → 0.9.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
@@ -7,6 +7,129 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - The `postgres-mcp` command is now a runtime launcher (`bin/postgres-mcp.mjs`)
13
+ that prefers the [oam](https://oamjs.org) runtime and falls back to Node.
14
+ Selection is via `POSTGRES_MCP_RUNTIME` (`auto` | `oam` | `node`, default
15
+ `auto`) and `OAM_BIN`.
16
+
17
+ The fallback costs nothing: npm already started Node to run the launcher, so
18
+ falling back is an `import()` into that same process -- no second spawn, no
19
+ extra startup, behavior identical to running `dist/index.js` directly. Users
20
+ without oam see no change and no stderr noise.
21
+
22
+ Equivalence on the oam path is verified end to end, not assumed: all 21 tools
23
+ register, a live query returns identical rows and `dataTypeName` values, and
24
+ the error paths match. oam provides every `node:` builtin the pg driver needs
25
+ (`net`, `tls`, `crypto`, `dns`), so SCRAM auth and the extended query protocol
26
+ both work.
27
+
28
+ **Latency, stated plainly:** taking the oam path means Node has booted first,
29
+ so both startups are paid. On windows-arm64 against the 1.4 MB bundle, Node
30
+ alone is ~650-900ms, oam alone ~980-1290ms, and launcher-to-oam ~1.8s. That is
31
+ a one-time cost per MCP session rather than per tool call, but it is a real
32
+ regression against plain Node -- `POSTGRES_MCP_RUNTIME=node` opts out.
33
+
34
+ > **Version note:** the "Changed (breaking)" entries below alter the shape of
35
+ > tool output and the CLI's exit behavior. Under SemVer-for-0.x that makes the
36
+ > next release a MINOR bump -- `0.8.0`, not `0.7.1`. `release.sh` performs the
37
+ > actual bump (`npm version`) and syncs `server.json`, so nothing is pre-bumped
38
+ > here; pass `0.8.0` when cutting the release.
39
+
40
+ ### Changed (breaking)
41
+
42
+ - `QueryResult.command` is now OPTIONAL, and is omitted entirely for statements
43
+ that run on the cursor path (every SELECT and other row-returning statement).
44
+ Previously it reported `"FETCH"` for all of them -- the command tag of the
45
+ internal `FETCH`, not of the user's statement. Postgres does not surface the
46
+ inner tag through a cursor and there is no source of truth to substitute, so
47
+ the field is absent rather than wrong. It is still present and correct on the
48
+ direct-exec path (DDL and DML without `RETURNING`), where node-pg reports the
49
+ first word of the real tag: `CREATE`, `INSERT`, etc.
50
+ - `rowCount` no longer exceeds `rows.length` on a truncated cursor-path result.
51
+ The bounded fetch deliberately reads `POSTGRES_MAX_ROWS + 1` rows to detect
52
+ truncation, and that extra probe row was leaking into `rowCount` -- callers
53
+ saw `rowCount: 1001` next to 1000 rows. The direct-exec path is unchanged and
54
+ still reports the AFFECTED-row count, which is independent of how many rows
55
+ come back: a truncated `INSERT ... RETURNING` of 10 rows correctly reports
56
+ `rowCount: 10`, `rows.length: 3`, `truncated: true`.
57
+ - `pg_top_queries` is now scoped to the database in `DATABASE_URL`.
58
+ `pg_stat_statements` is cluster-wide, so on a shared cluster the tool
59
+ previously returned normalized query text from unrelated databases. Results
60
+ are filtered by `dbid`, matching every other tool in this server. Callers who
61
+ relied on the cluster-wide view will see fewer rows.
62
+ - The CLI now exits 1 with a usage message on an unrecognized bare argument
63
+ instead of silently starting the stdio server. `postgres-mcp doctor` used to
64
+ print nothing and appear to hang while the server waited for MCP framing on
65
+ stdin. Arguments beginning with `-` are still passed through untouched so
66
+ host-supplied flags keep working, and a positionally-passed connection string
67
+ gets a targeted message pointing at the `DATABASE_URL` env block.
68
+
69
+ ### Fixed
70
+
71
+ - `pg_describe_table` no longer reports `INCLUDE` (covering) columns as part of
72
+ `primary_key`. PostgreSQL 11+ allows `PRIMARY KEY (id) INCLUDE (label)`, and
73
+ the covering column sits in `pg_index.indkey` next to the key column; the
74
+ query matched the whole vector. An agent reading that would build an invalid
75
+ `ON CONFLICT (id, label)` target. The key columns are now bounded by
76
+ `indnkeyatts`.
77
+ - `resolveTypeNames` no longer throws when `shutdown()` lands mid-flight. The
78
+ module-scoped type cache is nulled by `shutdown()`, and dereferencing it after
79
+ an await (SIGTERM during a tool call, or a test calling `shutdown()` between
80
+ calls) raised on null -- silently costing the response its `dataTypeName`
81
+ fields. The cache is now bound to a local before the first await.
82
+ - Zod schema defaults are re-applied consistently by every tool handler for
83
+ direct (non-MCP) callers, which bypass schema parsing. Previously only
84
+ `pg_explain` and `pg_table_bloat` did this; `pg_advisor` in particular bound
85
+ `undefined` into `n.nspname = ANY($1)` and errored at bind time. `pg_kill`
86
+ defaults to the safer `cancel` mode, so an omitted `mode` can never escalate
87
+ to `terminate`.
88
+
89
+ ### Documentation
90
+
91
+ - `pg_readonly`'s description and the README now state the actual scope of
92
+ `BEGIN READ ONLY`: it bounds writes to the DATABASE, not every side effect.
93
+ Functions whose effect lands outside the table data -- `pg_terminate_backend`,
94
+ `pg_cancel_backend`, `pg_read_file`, `lo_export`, `COPY ... TO PROGRAM` -- are
95
+ not blocked by it and are not behind the `ALLOW_WRITES` gate that `pg_kill`
96
+ sits behind. All of them still require privileges the `DATABASE_URL` role must
97
+ hold, so the ROLE is what actually bounds this tool. Auto-allow `pg_readonly`
98
+ with a least-privileged role.
99
+ - `release.sh` no longer suggests `npm login --auth-type=web` on an E401/E404.
100
+ That command overwrites the automation token in `~/.npmrc` with a
101
+ WebAuthn-bound session, and the next publish then fails on a challenge no
102
+ script can answer. It now points at restoring the automation token.
103
+ - Removed stale references to a CI pipeline this repo does not have: there is no
104
+ `.github/`, the binary build and the lint gate are both local.
105
+
106
+ ### Testing
107
+
108
+ - `src/index.test.ts`: the CLI entrypoint had no automated coverage at all (it
109
+ cannot be imported -- it calls `server.connect` at the top level), including
110
+ the argv handling that runs before server startup. Now driven as a child
111
+ process: both version flags, the argv guard's three branches, and a full MCP
112
+ `initialize` + `tools/list` handshake that covers the tool-registration wiring.
113
+ - Coverage for the `DECLARE`-succeeded / `FETCH`-failed branch of
114
+ `runUserQueryBounded`, which prevents re-executing a statement whose side
115
+ effects already landed. Engineered with a short `statement_timeout`; a
116
+ sequence acts as the double-execution detector, since `nextval` is
117
+ non-transactional and survives the rollback.
118
+ - Coverage for `dbid` scoping (with a positive control proving the assertion is
119
+ not vacuous), the truncated `INSERT ... RETURNING` row count, `command`
120
+ presence on both paths, `PRIMARY KEY ... INCLUDE`, composite-PK ordering,
121
+ `shutdown()` mid-bootstrap, and `getPool()` without `DATABASE_URL` including
122
+ the win32-only hint branch.
123
+ - `scripts/wsl-pg-setup.sh` now provisions PostgreSQL 15 alongside 17 and 18,
124
+ adds `pg_stat_statements` to `shared_preload_libraries`, and creates
125
+ `pg_stat_statements` + `pgstattuple`. Without those extensions present, every
126
+ `pg_top_queries` test and both `pg_table_bloat` `approx`/`exact` tests took
127
+ their "extension not installed" early return and proved nothing about the
128
+ tool's SQL. PG15 is there for column coverage, not recency:
129
+ `pg_stat_statements` renamed `blk_read_time` to `shared_blk_read_time` in 1.11
130
+ (PG17), and with only 17/18 in the matrix the pre-1.11 branch was never
131
+ selected.
132
+
10
133
  ## [0.6.20] - 2026-06-04
11
134
 
12
135
  ### Fixed
package/README.md CHANGED
@@ -27,7 +27,7 @@ None of them position themselves as a general-purpose daily driver you'd hand to
27
27
 
28
28
  ## Why this one?
29
29
 
30
- - **Read-only by default, with an unconditional read-only tool too** - `pg_query` runs user SQL in a `BEGIN READ ONLY` transaction, so postgres itself (not string parsing) blocks writes; opt in to writes with `ALLOW_WRITES=1`. `pg_readonly` is a separate tool that stays read-only regardless of `ALLOW_WRITES`, so hosts that gate tools individually (Claude Code permissions, mcp.hosting) can safely auto-allow it.
30
+ - **Read-only by default, with an unconditional read-only tool too** - `pg_query` runs user SQL in a `BEGIN READ ONLY` transaction, so postgres itself (not string parsing) blocks writes; opt in to writes with `ALLOW_WRITES=1`. `pg_readonly` is a separate tool that stays read-only regardless of `ALLOW_WRITES`, so hosts that gate tools individually (Claude Code permissions, mcp.hosting) can auto-allow it -- paired with a least-privileged role, since `READ ONLY` bounds writes to the database rather than every side effect ([details](#per-tool-gating-in-the-host)).
31
31
  - **Role-based access as the primary control** - the recommended posture is to use a least-privileged postgres role in `DATABASE_URL` (e.g. one with `GRANT pg_read_all_data`); postgres itself then enforces the boundary, no env var needed. See [Configuring access](#configuring-access).
32
32
  - **Extended query protocol for all user SQL** - `pg_query` sends user input with `queryMode: 'extended'`, which restricts each request to a single statement. This closes the [stacked-query injection class](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/) (`COMMIT; DROP SCHEMA x CASCADE;`) that defeated the reference server's `BEGIN READ ONLY` wrapper. Integration test asserts the rejection.
33
33
  - **Parameterized queries** - `pg_query` takes a `params` array for `$1`, `$2`, etc. No string-interpolated SQL in our code path.
@@ -132,6 +132,8 @@ Tools split cleanly across two authority classes:
132
132
 
133
133
  Claude Code's `permissions` block and mcp.hosting's per-tool toggle both honor this split.
134
134
 
135
+ > **What `READ ONLY` does and does not cover.** A `BEGIN READ ONLY` transaction blocks writes to the *database* -- INSERT/UPDATE/DELETE, DDL, `nextval`/`setval`. It does not block functions whose effect lands outside the table data. `SELECT pg_terminate_backend(...)`, `pg_cancel_backend`, `pg_read_file`, `lo_export`, and `COPY ... TO PROGRAM` all run to completion inside `pg_readonly`, which means auto-allowing `pg_readonly` reaches the same capability that `pg_kill` puts behind `ALLOW_WRITES=1`. Every one of them still requires a privilege the `DATABASE_URL` role must actually hold (`pg_signal_backend`, `pg_read_server_files`, superuser), so **the role is the control that bounds this tool, not the transaction mode.** If you auto-allow `pg_readonly`, use a least-privileged role -- see [Configuring access](#configuring-access).
136
+
135
137
  **`ALLOW_WRITES` as defense-in-depth:**
136
138
 
137
139
  `ALLOW_WRITES` is a secondary belt-and-braces gate. Useful when:
@@ -161,7 +163,7 @@ The bigger leverage is multi-tool reasoning. A few real workflows:
161
163
 
162
164
  | Tool | Description |
163
165
  |------|-------------|
164
- | `pg_readonly` | Run a SQL statement guaranteed read-only - always inside `BEGIN READ ONLY`, regardless of `ALLOW_WRITES`. The recommended tool for read access; safe for hosts to auto-allow. |
166
+ | `pg_readonly` | Run a SQL statement with no persistent data changes - always inside `BEGIN READ ONLY`, regardless of `ALLOW_WRITES`. The recommended tool for read access, and the one to auto-allow; pair it with a least-privileged role ([why](#per-tool-gating-in-the-host)). |
165
167
  | `pg_query` | Run a SQL query. Writes gated by the role in `DATABASE_URL` first, `ALLOW_WRITES` second. Supports parameterized queries via `params`. Result fields include `dataTypeName` (e.g. `int4`, `jsonb`) alongside `dataTypeID`. |
166
168
  | `pg_list_schemas` | List non-system schemas. |
167
169
  | `pg_list_tables` | List tables (and optionally views) in a schema with estimated row counts. Paginated via `limit`/`offset`. |
@@ -196,10 +198,37 @@ All env vars are read from the MCP server's environment:
196
198
  | `POSTGRES_MAX_ROWS` | `1000` | Cap on rows returned by `pg_query`. |
197
199
  | `POSTGRES_POOL_MAX` | `5` | Max pool connections. Set to `1` for single-threaded backends (pglite-socket, PgBouncer transaction mode). |
198
200
  | `POSTGRES_SSL_REJECT_UNAUTHORIZED` | unset | Set to `false` to skip TLS cert verification (for managed DBs using private-CA certs). Connection is still encrypted. |
201
+ | `POSTGRES_MCP_RUNTIME` | `auto` | Which JS runtime executes the server: `auto` (prefer [oam](https://oamjs.org), fall back to Node), `oam` (require oam, fail if absent), `node` (never use oam). See [Runtime](#runtime). |
202
+ | `OAM_BIN` | unset | Explicit path to an `oam` binary, checked before PATH and the default install locations. |
199
203
 
200
204
  ### Supported Postgres versions
201
205
 
202
- Tested on **PostgreSQL 17 and 18** in CI. Should work on PG13+ -- a few tools (`pg_replication_status` reading `wal_status`, `pg_top_queries` reading `*_exec_time`) rely on columns that landed in PG13. PG12 and below are out of upstream support and not exercised here.
206
+ Tested on **PostgreSQL 15, 17 and 18** in the integration matrix. Should work on PG13+ -- a few tools (`pg_replication_status` reading `wal_status`, `pg_top_queries` reading `*_exec_time`) rely on columns that landed in PG13. PG12 and below are out of upstream support and not exercised here.
207
+
208
+ ### Runtime
209
+
210
+ The published `postgres-mcp` command is a small launcher that prefers the [oam](https://oamjs.org) runtime and falls back to Node.
211
+
212
+ **If you do not have oam, nothing changes.** The fallback is not a re-exec: npm already started Node to run the launcher, so falling back is a plain `import()` of the server into that same process. It costs a few `existsSync` calls and no subprocess, and behaves identically to running `dist/index.js` under Node directly.
213
+
214
+ **If you do have oam,** the server runs under it. Verified equivalent on both runtimes: all 21 tools register, queries return identical rows and `dataTypeName` values, and the error paths match. oam supplies every `node:` builtin the driver needs, including `net`, `tls`, `crypto`, and `dns` (SCRAM auth and the extended query protocol both work).
215
+
216
+ **Cost, stated plainly.** Taking the oam path means Node has already booted, so you pay both startups. Measured on windows-arm64 against the 1.4 MB bundle: Node alone ~650-900ms, oam alone ~980-1290ms, launcher-to-oam ~1.8s. This is a **one-time cost per MCP session**, not per tool call -- hosts spawn the server once and hold it open -- but if you care about launch latency, set `POSTGRES_MCP_RUNTIME=node`.
217
+
218
+ ```jsonc
219
+ {
220
+ "mcpServers": {
221
+ "postgres": {
222
+ "command": "npx",
223
+ "args": ["-y", "@yawlabs/postgres-mcp"],
224
+ "env": {
225
+ "DATABASE_URL": "postgres://...",
226
+ "POSTGRES_MCP_RUNTIME": "node" // opt out of oam
227
+ }
228
+ }
229
+ }
230
+ }
231
+ ```
203
232
 
204
233
  ### Connecting to managed Postgres (Supabase, Neon, RDS, etc.)
205
234
 
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime launcher for @yawlabs/postgres-mcp.
4
+ *
5
+ * Prefers the oam runtime (https://oamjs.org) and falls back to the Node
6
+ * process already running this file. The server itself (`dist/index.js`) is
7
+ * runtime-agnostic -- it is a pre-bundled ESM file using only `node:` builtins
8
+ * that oam implements -- so neither path changes behavior.
9
+ *
10
+ * WHY THE FALLBACK COSTS NOTHING
11
+ * The fallback does NOT re-exec node. npm already started a node process to
12
+ * run this launcher, so falling back is a plain `import()` of the server into
13
+ * THIS process: zero extra spawn, zero extra startup, byte-identical behavior
14
+ * to invoking `dist/index.js` directly. Users without oam pay only the cost of
15
+ * resolving a few paths (a handful of `existsSync` calls, no subprocess).
16
+ *
17
+ * WHAT THE OAM PATH COSTS
18
+ * Taking the oam path means node has already booted, so the total is node's
19
+ * startup plus oam's. Measured on windows-arm64 against the 1.4 MB bundle:
20
+ * node alone ~650-900ms, oam alone ~980-1290ms, so the oam path lands near
21
+ * ~1.8s. This is a ONE-TIME cost per MCP session, not per tool call -- hosts
22
+ * spawn the server once and keep it -- but it is a real regression against
23
+ * plain node and the reason `POSTGRES_MCP_RUNTIME=node` exists.
24
+ *
25
+ * SELECTION
26
+ * POSTGRES_MCP_RUNTIME=oam require oam; fail loudly if it is missing
27
+ * POSTGRES_MCP_RUNTIME=node never use oam
28
+ * POSTGRES_MCP_RUNTIME=auto prefer oam, silently fall back (default)
29
+ * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
30
+ */
31
+
32
+ import { spawn } from "node:child_process";
33
+ import { existsSync } from "node:fs";
34
+ import { constants, homedir } from "node:os";
35
+ import { delimiter, join } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
39
+ // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
40
+ // in-process fallback must use the file:// URL. spawn(), conversely, needs a
41
+ // real filesystem path. Keeping both avoids converting at each call site and
42
+ // getting it backwards on one of them.
43
+ const SERVER_URL = new URL("../dist/index.js", import.meta.url);
44
+ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
45
+ const isWin = process.platform === "win32";
46
+ const exe = isWin ? "oam.exe" : "oam";
47
+
48
+ /**
49
+ * Locate an oam binary, or null. Ordered cheapest-and-most-explicit first;
50
+ * every branch is a stat, never a subprocess, so the miss case (the common one
51
+ * for users who have never heard of oam) stays sub-millisecond.
52
+ */
53
+ function findOam() {
54
+ // 1. Explicit override wins and is never second-guessed.
55
+ const override = process.env.OAM_BIN;
56
+ if (override) return existsSync(override) ? override : null;
57
+
58
+ // 2. PATH. Resolved manually rather than by spawning `which`/`where`, which
59
+ // would cost a subprocess on every launch just to decide whether to spawn.
60
+ const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
61
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
62
+ if (!dir) continue;
63
+ for (const ext of isWin ? pathExt : [""]) {
64
+ const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
65
+ if (existsSync(candidate)) return candidate;
66
+ }
67
+ }
68
+
69
+ // 3. The per-user locations oamjs.org's installers write to. Checked because
70
+ // an MCP host launched from a GUI often has a PATH that does not include
71
+ // them, so PATH-only discovery would miss an oam the user really has.
72
+ const installed = isWin
73
+ ? [join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe)]
74
+ : [join(homedir(), ".oam", "bin", exe)];
75
+ for (const candidate of installed) {
76
+ if (existsSync(candidate)) return candidate;
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ /** Run the server in THIS process. The zero-overhead fallback. */
83
+ async function runInProcess() {
84
+ await import(SERVER_URL.href);
85
+ }
86
+
87
+ const mode = (process.env.POSTGRES_MCP_RUNTIME ?? "auto").toLowerCase();
88
+
89
+ if (mode === "node") {
90
+ await runInProcess();
91
+ } else {
92
+ const oam = findOam();
93
+
94
+ if (!oam) {
95
+ if (mode === "oam") {
96
+ // Explicitly demanded, so this is a real misconfiguration -- do not
97
+ // silently do something else. writeSync because stderr is async for
98
+ // TTYs/pipes on Windows and process.exit truncates pending writes.
99
+ const { writeSync } = await import("node:fs");
100
+ writeSync(
101
+ 2,
102
+ "postgres-mcp: POSTGRES_MCP_RUNTIME=oam but no oam binary was found.\n" +
103
+ "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use POSTGRES_MCP_RUNTIME=node.\n",
104
+ );
105
+ process.exit(1);
106
+ }
107
+ await runInProcess();
108
+ } else {
109
+ // `--` separates oam's own flags from the script's argv. Everything after
110
+ // it lands in process.argv for the server, so `postgres-mcp version` and
111
+ // any host-supplied flags survive the hop unchanged.
112
+ const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
113
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
114
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
115
+ // server's shutdown path.
116
+ stdio: "inherit",
117
+ env: process.env,
118
+ windowsHide: true,
119
+ });
120
+
121
+ // If oam cannot be executed at all (deleted between the stat and the
122
+ // spawn, wrong arch, permission), fall back rather than failing the whole
123
+ // server. `spawned` guards against falling back AFTER the child has begun
124
+ // running, which would double-start the server.
125
+ let spawned = false;
126
+ child.on("spawn", () => {
127
+ spawned = true;
128
+ });
129
+ child.on("error", (err) => {
130
+ if (spawned) return;
131
+ if (mode === "oam") {
132
+ process.stderr.write(`postgres-mcp: failed to launch oam (${err.message})\n`);
133
+ process.exit(1);
134
+ }
135
+ void runInProcess();
136
+ });
137
+
138
+ // Forward termination so the server's own SIGINT/SIGTERM cleanup (pool
139
+ // drain) runs in the child instead of the child being orphaned. Signals
140
+ // are a no-op on Windows but harmless to register.
141
+ for (const sig of ["SIGINT", "SIGTERM"]) {
142
+ process.on(sig, () => {
143
+ if (!child.killed) child.kill(sig);
144
+ });
145
+ }
146
+
147
+ child.on("exit", (code, signal) => {
148
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
149
+ // conventional shell exit status rather than a bare 0.
150
+ if (signal) {
151
+ process.exit(128 + (constants.signals[signal] ?? 15));
152
+ }
153
+ process.exit(code ?? 0);
154
+ });
155
+ }
156
+ }
package/dist/index.js CHANGED
@@ -11996,6 +11996,9 @@ var require_lib2 = __commonJS({
11996
11996
  }
11997
11997
  });
11998
11998
 
11999
+ // src/index.ts
12000
+ import { writeSync } from "node:fs";
12001
+
11999
12002
  // node_modules/zod/v3/helpers/util.js
12000
12003
  var util;
12001
12004
  (function(util2) {
@@ -36144,22 +36147,24 @@ function getPool() {
36144
36147
  var typeNameCache = null;
36145
36148
  async function resolveTypeNames(client, oids) {
36146
36149
  if (oids.length === 0) return {};
36147
- if (!typeNameCache) {
36148
- typeNameCache = /* @__PURE__ */ new Map();
36150
+ let cache = typeNameCache;
36151
+ if (!cache) {
36152
+ cache = /* @__PURE__ */ new Map();
36153
+ typeNameCache = cache;
36149
36154
  const res = await client.query("SELECT oid, typname FROM pg_catalog.pg_type");
36150
- for (const row of res.rows) typeNameCache.set(row.oid, row.typname);
36155
+ for (const row of res.rows) cache.set(row.oid, row.typname);
36151
36156
  }
36152
- const missing = oids.filter((o) => !typeNameCache?.has(o));
36157
+ const missing = oids.filter((o) => !cache.has(o));
36153
36158
  if (missing.length > 0) {
36154
36159
  const res = await client.query(
36155
36160
  "SELECT oid, typname FROM pg_catalog.pg_type WHERE oid = ANY($1)",
36156
36161
  [missing]
36157
36162
  );
36158
- for (const row of res.rows) typeNameCache.set(row.oid, row.typname);
36163
+ for (const row of res.rows) cache.set(row.oid, row.typname);
36159
36164
  }
36160
36165
  const out = {};
36161
36166
  for (const oid of oids) {
36162
- const n = typeNameCache.get(oid);
36167
+ const n = cache.get(oid);
36163
36168
  if (n !== void 0) out[oid] = n;
36164
36169
  }
36165
36170
  return out;
@@ -36192,18 +36197,19 @@ async function runUserQueryBounded(client, sql, params, maxRows) {
36192
36197
  } catch {
36193
36198
  }
36194
36199
  await client.query("RELEASE SAVEPOINT __pgmcp_sp");
36195
- return fetched;
36200
+ return { result: fetched, viaCursor: true };
36196
36201
  } catch (err) {
36197
36202
  if (declareSucceeded) {
36198
36203
  throw err;
36199
36204
  }
36200
36205
  await client.query("ROLLBACK TO SAVEPOINT __pgmcp_sp");
36201
36206
  await client.query("RELEASE SAVEPOINT __pgmcp_sp");
36202
- return client.query({
36207
+ const direct = await client.query({
36203
36208
  text: sql,
36204
36209
  values: params,
36205
36210
  queryMode: "extended"
36206
36211
  });
36212
+ return { result: direct, viaCursor: false };
36207
36213
  }
36208
36214
  }
36209
36215
  async function safeResolveTypeNames(client, fields) {
@@ -36214,17 +36220,29 @@ async function safeResolveTypeNames(client, fields) {
36214
36220
  return {};
36215
36221
  }
36216
36222
  }
36217
- function toQueryResult(result, maxRows, typeNames = {}) {
36223
+ function toQueryResult(result, maxRows, typeNames, viaCursor) {
36218
36224
  const truncated = result.rows.length > maxRows;
36219
36225
  const rows = truncated ? result.rows.slice(0, maxRows) : result.rows;
36220
36226
  return {
36221
36227
  rows,
36222
- rowCount: result.rowCount,
36228
+ // Cursor path only: we deliberately FETCH maxRows + 1 to detect
36229
+ // truncation, so `result.rowCount` is one MORE than what we return --
36230
+ // consumers saw rowCount=1001 next to 1000 rows. Report what's in `rows`.
36231
+ //
36232
+ // The direct-exec path must NOT be rewritten. There `rowCount` is the
36233
+ // affected-row count, which is independent of how many rows came back:
36234
+ // `INSERT ... RETURNING` is non-cursorable (DECLARE rejects it with
36235
+ // 42601), so a 10-row insert truncated to 3 must still report 10 rows
36236
+ // affected. Collapsing it to rows.length told the caller 3 rows were
36237
+ // written when 10 were committed.
36238
+ rowCount: truncated && viaCursor ? rows.length : result.rowCount,
36223
36239
  fields: result.fields.map((f) => {
36224
36240
  const name = typeNames[f.dataTypeID];
36225
36241
  return name !== void 0 ? { name: f.name, dataTypeID: f.dataTypeID, dataTypeName: name } : { name: f.name, dataTypeID: f.dataTypeID };
36226
36242
  }),
36227
- command: result.command,
36243
+ // See QueryResult.command -- omitted on the cursor path because the tag
36244
+ // there describes the FETCH, not the user's statement.
36245
+ ...viaCursor ? {} : { command: result.command },
36228
36246
  ...truncated ? { truncated: true } : {}
36229
36247
  };
36230
36248
  }
@@ -36234,10 +36252,10 @@ async function runReadOnly(sql, params = [], hooks = {}) {
36234
36252
  try {
36235
36253
  await client.query("BEGIN READ ONLY");
36236
36254
  if (hooks.setup) await hooks.setup(client);
36237
- const result = await runUserQueryBounded(client, sql, params, maxRows);
36255
+ const { result, viaCursor } = await runUserQueryBounded(client, sql, params, maxRows);
36238
36256
  await client.query("ROLLBACK");
36239
36257
  const typeNames = await safeResolveTypeNames(client, result.fields);
36240
- return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
36258
+ return { ok: true, data: toQueryResult(result, maxRows, typeNames, viaCursor) };
36241
36259
  } catch (err) {
36242
36260
  try {
36243
36261
  await client.query("ROLLBACK");
@@ -36265,10 +36283,10 @@ async function runReadWrite(sql, params = []) {
36265
36283
  const maxRows = getMaxRows();
36266
36284
  try {
36267
36285
  await client.query("BEGIN");
36268
- const result = await runUserQueryBounded(client, sql, params, maxRows);
36286
+ const { result, viaCursor } = await runUserQueryBounded(client, sql, params, maxRows);
36269
36287
  await client.query("COMMIT");
36270
36288
  const typeNames = await safeResolveTypeNames(client, result.fields);
36271
- return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
36289
+ return { ok: true, data: toQueryResult(result, maxRows, typeNames, viaCursor) };
36272
36290
  } catch (err) {
36273
36291
  try {
36274
36292
  await client.query("ROLLBACK");
@@ -36291,10 +36309,10 @@ async function runReadWriteRollback(sql, params = [], hooks = {}) {
36291
36309
  try {
36292
36310
  await client.query("BEGIN");
36293
36311
  if (hooks.setup) await hooks.setup(client);
36294
- const result = await runUserQueryBounded(client, sql, params, maxRows);
36312
+ const { result, viaCursor } = await runUserQueryBounded(client, sql, params, maxRows);
36295
36313
  await client.query("ROLLBACK");
36296
36314
  const typeNames = await safeResolveTypeNames(client, result.fields);
36297
- return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
36315
+ return { ok: true, data: toQueryResult(result, maxRows, typeNames, viaCursor) };
36298
36316
  } catch (err) {
36299
36317
  try {
36300
36318
  await client.query("ROLLBACK");
@@ -36445,7 +36463,7 @@ var adminTools = [
36445
36463
  limit: external_exports.number().int().min(1).max(100).default(50).describe("Max blocked/blocker pairs (default 50).")
36446
36464
  }),
36447
36465
  handler: async (input) => {
36448
- const { limit } = input;
36466
+ const { limit = 50 } = input;
36449
36467
  return runInternal(
36450
36468
  `SELECT
36451
36469
  blocked.pid AS blocked_pid,
@@ -36519,7 +36537,7 @@ var adminTools = [
36519
36537
  includeSystem: external_exports.boolean().default(false).describe("If true, include built-in `pg_*` roles (pg_read_all_data, pg_monitor, etc.).")
36520
36538
  }),
36521
36539
  handler: async (input) => {
36522
- const { includeSystem } = input;
36540
+ const { includeSystem = false } = input;
36523
36541
  const filter = includeSystem ? "" : "WHERE NOT starts_with(r.rolname, 'pg_')";
36524
36542
  return runInternal(
36525
36543
  // Cast member_of to text[] so node-pg parses it into a JS array.
@@ -36560,7 +36578,7 @@ var adminTools = [
36560
36578
  table: identSchema.optional().describe("Table name. Omit to list privileges for all tables in the schema.")
36561
36579
  }),
36562
36580
  handler: async (input) => {
36563
- const { schema, table } = input;
36581
+ const { schema = "public", table } = input;
36564
36582
  const tableFilter = table ? "AND table_name = $2" : "";
36565
36583
  const params = [schema];
36566
36584
  if (table) params.push(table);
@@ -36593,7 +36611,7 @@ var adminTools = [
36593
36611
  mode: external_exports.enum(["cancel", "terminate"]).default("cancel").describe("`cancel` aborts the current query; `terminate` closes the connection entirely.")
36594
36612
  }),
36595
36613
  handler: async (input) => {
36596
- const { pid, mode } = input;
36614
+ const { pid, mode = "cancel" } = input;
36597
36615
  if (!isWritesAllowed()) {
36598
36616
  return {
36599
36617
  ok: false,
@@ -36705,7 +36723,11 @@ var adminTools = [
36705
36723
  limit: external_exports.number().int().min(1).max(500).default(50).describe("Max rows per category (default 50).")
36706
36724
  }),
36707
36725
  handler: async (input) => {
36708
- const { seqExhaustionThreshold, rlsSchemas, limit } = input;
36726
+ const {
36727
+ seqExhaustionThreshold = 0.5,
36728
+ rlsSchemas = ["public"],
36729
+ limit = 50
36730
+ } = input;
36709
36731
  return withSharedClient(async (run) => {
36710
36732
  const [seqRes, noPkRes, rlsRes] = await Promise.all([
36711
36733
  run(
@@ -36815,11 +36837,10 @@ var adminTools = [
36815
36837
  handler: async (input) => {
36816
36838
  const {
36817
36839
  schema,
36818
- minDeadRatio,
36819
- limit,
36820
- method: rawMethod
36840
+ minDeadRatio = 0.1,
36841
+ limit = 50,
36842
+ method = "estimate"
36821
36843
  } = input;
36822
- const method = rawMethod ?? "estimate";
36823
36844
  const schemaFilter = schema ? "AND schemaname = $3" : "AND schemaname NOT IN ('pg_catalog', 'information_schema') AND schemaname NOT LIKE 'pg_%'";
36824
36845
  const params = [minDeadRatio, limit];
36825
36846
  if (schema) params.push(schema);
@@ -36973,13 +36994,11 @@ var explainTools = [
36973
36994
  handler: async (input) => {
36974
36995
  const {
36975
36996
  sql,
36976
- analyze: rawAnalyze,
36977
- format: rawFormat,
36997
+ analyze = false,
36998
+ format = "text",
36978
36999
  params,
36979
37000
  hypothetical_indexes
36980
37001
  } = input;
36981
- const analyze = rawAnalyze ?? false;
36982
- const format = rawFormat ?? "text";
36983
37002
  if (/^\s*EXPLAIN\b/i.test(sql)) {
36984
37003
  return {
36985
37004
  ok: false,
@@ -37046,7 +37065,7 @@ var healthTools = [
37046
37065
  activeQueryLimit: external_exports.number().int().min(1).max(100).default(10).describe("Max active queries to return (default 10, max 100).")
37047
37066
  }),
37048
37067
  handler: async (input) => {
37049
- const { activeQueryLimit } = input;
37068
+ const { activeQueryLimit = 10 } = input;
37050
37069
  return withSharedClient(async (run) => {
37051
37070
  const [versionRes, sizeRes, connsRes, activeRes, tableCountRes] = await Promise.all([
37052
37071
  run(`SELECT version() AS version`),
@@ -37121,7 +37140,18 @@ var healthTools = [
37121
37140
  var queryTools = [
37122
37141
  {
37123
37142
  name: "pg_readonly",
37124
- description: "Run a SQL statement guaranteed read-only. Always executes inside a `BEGIN READ ONLY` transaction regardless of `ALLOW_WRITES`, so postgres itself rejects any write attempt. Use this whenever the goal is to read - SELECT, EXPLAIN, SHOW, VALUES, WITH ... SELECT, etc. Hosts that gate tools individually (Claude Code permissions, mcp.hosting) can safely auto-allow this one. 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). Large result sets are truncated to POSTGRES_MAX_ROWS (default 1000) with a `truncated: true` flag.",
37143
+ description: "Run a SQL statement with no persistent data changes. Always executes inside a `BEGIN READ ONLY` transaction regardless of `ALLOW_WRITES`, so postgres itself rejects any INSERT/UPDATE/DELETE/DDL and the transaction is always rolled back. Use this whenever the goal is to read - SELECT, EXPLAIN, SHOW, VALUES, WITH ... SELECT, etc. Scope caveat for hosts that auto-allow this tool: `READ ONLY` constrains writes to the DATABASE, not every side effect. Functions whose effect is outside the table data - `pg_cancel_backend` / `pg_terminate_backend`, `pg_read_file`, `lo_export`, `COPY ... TO PROGRAM` - are NOT blocked here and are NOT behind the `ALLOW_WRITES` gate that `pg_kill` sits behind. They still require the privileges the `DATABASE_URL` role holds, so a least-privileged role (e.g. `pg_read_all_data`) is what actually bounds this tool. 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). Large result sets are truncated to POSTGRES_MAX_ROWS (default 1000) with a `truncated: true` flag.",
37144
+ // DELIBERATE, do not "fix" to match the caveat in the description above.
37145
+ // `BEGIN READ ONLY` does not block side-effecting functions
37146
+ // (pg_terminate_backend, pg_read_file, COPY ... TO PROGRAM), so these
37147
+ // hints are arguably too generous, and a review pass will keep noticing
37148
+ // that. The decision is to keep them: staying in the host auto-allow class
37149
+ // is the entire reason pg_readonly exists as a separate tool from
37150
+ // pg_query, and the DATABASE_URL role -- not the transaction mode -- is
37151
+ // the control that actually bounds this tool. The description and the
37152
+ // README carry the caveat; a least-privileged role is the enforcement.
37153
+ // Flipping these to destructive would move pg_readonly to "always prompt"
37154
+ // in every existing host config for a bound the role already provides.
37125
37155
  annotations: {
37126
37156
  title: "Run read-only SQL",
37127
37157
  readOnlyHint: true,
@@ -37206,7 +37236,12 @@ var schemaTools = [
37206
37236
  offset: external_exports.number().int().min(0).default(0).describe("Rows to skip for pagination (default 0).")
37207
37237
  }),
37208
37238
  handler: async (input) => {
37209
- const { schema, includeViews, limit, offset } = input;
37239
+ const {
37240
+ schema = "public",
37241
+ includeViews = false,
37242
+ limit = 500,
37243
+ offset = 0
37244
+ } = input;
37210
37245
  const kinds = includeViews ? "('r', 'v', 'm', 'f', 'p')" : "('r', 'f', 'p')";
37211
37246
  return runInternal(
37212
37247
  `SELECT
@@ -37245,7 +37280,7 @@ var schemaTools = [
37245
37280
  table: identSchema.describe("Table name.")
37246
37281
  }),
37247
37282
  handler: async (input) => {
37248
- const { schema, table } = input;
37283
+ const { schema = "public", table } = input;
37249
37284
  const kindQuery = `
37250
37285
  SELECT
37251
37286
  CASE c.relkind
@@ -37282,11 +37317,12 @@ var schemaTools = [
37282
37317
  FROM pg_catalog.pg_index i
37283
37318
  JOIN pg_catalog.pg_class c ON c.oid = i.indrelid
37284
37319
  JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
37285
- JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)
37320
+ JOIN LATERAL unnest(i.indkey[0:i.indnkeyatts - 1]) WITH ORDINALITY AS k(attnum, ord) ON TRUE
37321
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum
37286
37322
  WHERE n.nspname = $1
37287
37323
  AND c.relname = $2
37288
37324
  AND i.indisprimary
37289
- ORDER BY array_position(i.indkey, a.attnum)
37325
+ ORDER BY k.ord
37290
37326
  `;
37291
37327
  const foreignKeysQuery = `
37292
37328
  SELECT
@@ -37466,7 +37502,7 @@ var schemaTools = [
37466
37502
  includeMaterialized: external_exports.boolean().default(true).describe("If true, include materialized views.")
37467
37503
  }),
37468
37504
  handler: async (input) => {
37469
- const { schema, includeMaterialized } = input;
37505
+ const { schema = "public", includeMaterialized = true } = input;
37470
37506
  const kinds = includeMaterialized ? "('v', 'm')" : "('v')";
37471
37507
  return runInternal(
37472
37508
  `SELECT
@@ -37496,7 +37532,7 @@ var schemaTools = [
37496
37532
  schema: identSchema.default("public").describe("Schema name (defaults to 'public').")
37497
37533
  }),
37498
37534
  handler: async (input) => {
37499
- const { schema } = input;
37535
+ const { schema = "public" } = input;
37500
37536
  return runInternal(
37501
37537
  `SELECT
37502
37538
  p.proname AS name,
@@ -37560,7 +37596,7 @@ var schemaTools = [
37560
37596
  limit: external_exports.number().int().min(1).max(1e3).default(100).describe("Max rows to return (default 100).")
37561
37597
  }),
37562
37598
  handler: async (input) => {
37563
- const { pattern, schema, limit } = input;
37599
+ const { pattern, schema, limit = 100 } = input;
37564
37600
  const schemaFilter = schema ? "AND n.nspname = $3" : "AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'";
37565
37601
  const params = [pattern, limit];
37566
37602
  if (schema) params.push(schema);
@@ -37591,7 +37627,7 @@ var schemaTools = [
37591
37627
  var statsTools = [
37592
37628
  {
37593
37629
  name: "pg_top_queries",
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).",
37630
+ 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). Scoped to the database in DATABASE_URL: pg_stat_statements is cluster-wide, so results are filtered by `dbid` to match every other tool here rather than leaking query text from unrelated databases sharing the cluster.",
37595
37631
  annotations: {
37596
37632
  title: "Top queries by execution time",
37597
37633
  readOnlyHint: true,
@@ -37604,7 +37640,7 @@ var statsTools = [
37604
37640
  limit: external_exports.number().int().min(1).max(100).default(20).describe("Number of rows to return (default 20).")
37605
37641
  }),
37606
37642
  handler: async (input) => {
37607
- const { orderBy, limit } = input;
37643
+ const { orderBy = "total_time", limit = 20 } = input;
37608
37644
  const versionRes = await runInternal(
37609
37645
  `SELECT extversion AS version FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'`
37610
37646
  );
@@ -37646,6 +37682,7 @@ var statsTools = [
37646
37682
  ELSE NULL
37647
37683
  END AS hit_percent${ioTimingCols}
37648
37684
  FROM pg_stat_statements
37685
+ WHERE dbid = (SELECT oid FROM pg_catalog.pg_database WHERE datname = current_database())
37649
37686
  ORDER BY ${orderCol} DESC NULLS LAST
37650
37687
  LIMIT $1`,
37651
37688
  [limit]
@@ -37668,7 +37705,11 @@ var statsTools = [
37668
37705
  limit: external_exports.number().int().min(1).max(100).default(20).describe("Max rows to return (default 20).")
37669
37706
  }),
37670
37707
  handler: async (input) => {
37671
- const { schema, minSize, limit } = input;
37708
+ const {
37709
+ schema,
37710
+ minSize = 1e3,
37711
+ limit = 20
37712
+ } = input;
37672
37713
  const schemaFilter = schema ? "AND schemaname = $3" : "AND schemaname NOT IN ('pg_catalog', 'information_schema') AND schemaname NOT LIKE 'pg_%'";
37673
37714
  const params = [minSize, limit];
37674
37715
  if (schema) params.push(schema);
@@ -37709,7 +37750,11 @@ var statsTools = [
37709
37750
  limit: external_exports.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
37710
37751
  }),
37711
37752
  handler: async (input) => {
37712
- const { schema, maxScans, limit } = input;
37753
+ const {
37754
+ schema,
37755
+ maxScans = 10,
37756
+ limit = 50
37757
+ } = input;
37713
37758
  const schemaFilter = schema ? "AND s.schemaname = $3" : "AND s.schemaname NOT IN ('pg_catalog', 'information_schema') AND s.schemaname NOT LIKE 'pg_%'";
37714
37759
  const params = [maxScans, limit];
37715
37760
  if (schema) params.push(schema);
@@ -37751,12 +37796,25 @@ function compareVersions(a, b) {
37751
37796
  }
37752
37797
 
37753
37798
  // src/index.ts
37754
- var version2 = true ? "0.7.0" : await readPackageVersion();
37799
+ var version2 = true ? "0.9.0" : await readPackageVersion();
37755
37800
  var subcommand = process.argv[2];
37756
37801
  if (subcommand === "version" || subcommand === "--version") {
37757
37802
  console.log(version2);
37758
37803
  process.exit(0);
37759
37804
  }
37805
+ if (subcommand !== void 0 && !subcommand.startsWith("-")) {
37806
+ const looksLikeDsn = /^postgres(ql)?:\/\//i.test(subcommand);
37807
+ const message = looksLikeDsn ? `postgres-mcp: connection strings are not accepted as an argument.
37808
+ Set DATABASE_URL in the MCP server env instead:
37809
+ "env": { "DATABASE_URL": "postgres://..." }
37810
+ ` : `postgres-mcp: unknown subcommand '${subcommand}'
37811
+ Usage:
37812
+ postgres-mcp start the MCP server on stdio
37813
+ postgres-mcp version print the version and exit
37814
+ `;
37815
+ writeSync(2, message);
37816
+ process.exit(1);
37817
+ }
37760
37818
  var allTools = [...queryTools, ...schemaTools, ...explainTools, ...healthTools, ...statsTools, ...adminTools];
37761
37819
  var server = new McpServer({
37762
37820
  name: "@yawlabs/postgres-mcp",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/postgres-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",
@@ -28,9 +28,10 @@
28
28
  "type": "module",
29
29
  "main": "dist/index.js",
30
30
  "bin": {
31
- "postgres-mcp": "dist/index.js"
31
+ "postgres-mcp": "bin/postgres-mcp.mjs"
32
32
  },
33
33
  "files": [
34
+ "bin/postgres-mcp.mjs",
34
35
  "dist/index.js",
35
36
  "LICENSE",
36
37
  "README.md",