@yawlabs/postgres-mcp 0.9.1 → 0.10.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,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-08-08
11
+
12
+ ### Added
13
+
14
+ - **An opt-in `--permission` sandbox under oam**, via `POSTGRES_MCP_SANDBOX=1`.
15
+ The network grant is derived from `DATABASE_URL` at launch rather than
16
+ hardcoded, so the one endpoint the server may reach is the one it was
17
+ configured to reach. Host and port are both pinned, because oam matches grants
18
+ by prefix and a bare host would also admit every other port on it. Filesystem
19
+ and child-process are denied outright.
20
+
21
+ Opt-in rather than default because a wrong grant does not fail loudly: oam
22
+ denies a non-granted environment variable by making it **absent** from
23
+ `process.env` rather than throwing, so an under-granted `DATABASE_URL` would
24
+ read as "not configured" instead of "denied". The environment allow-list is
25
+ derived from what the shipped bundle actually reads, which is why it includes
26
+ the pg driver's own lookups (`PGSSLMODE`, `PGCONNECT_TIMEOUT` and friends) that
27
+ a hand-written list would have missed.
28
+
29
+ ### Changed
30
+
31
+ - **oam 0.9.0 is now the minimum**, enforced in `bin/postgres-mcp.mjs`. Older
32
+ releases ran `child_process.execFile` arguments through a shell, accepted
33
+ `exec`'s `timeout` and ignored it, truncated `spawnSync` at `maxBuffer` while
34
+ reporting success, and treated `stdio: 'inherit'` as `'pipe'`. This server
35
+ spawns nothing, so the floor is enforced for consistency across
36
+ `@yawlabs/*-mcp` rather than because this launcher was exposed. An older oam is
37
+ not an error: the launcher falls back to Node and says so on stderr, and
38
+ `POSTGRES_MCP_RUNTIME=oam` turns that into a hard error.
39
+
40
+ ### Fixed
41
+
42
+ - **`release.sh` aborted instead of releasing when `[Unreleased]` was empty.**
43
+ The body extraction pipes through `grep -v` to drop blank lines, and `grep`
44
+ exits non-zero when it matches nothing — so under `set -e` an empty section
45
+ killed the script at that line, and the `warn` branch written to handle
46
+ exactly that case could never run.
47
+
10
48
  ## [0.9.1] - 2026-08-07
11
49
 
12
50
  ### Fixed
@@ -33,19 +33,48 @@
33
33
  * ~980-1290ms), which were wrong in both magnitude and direction. Warm every
34
34
  * candidate first, or stage it out of the build directory.
35
35
  *
36
+ * THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
37
+ * `POSTGRES_MCP_SANDBOX=1` runs the server under oam's permission model.
38
+ *
39
+ * The database host is not knowable ahead of time, so the net grant is DERIVED
40
+ * from DATABASE_URL at launch: the one endpoint this server may reach is the one
41
+ * it was configured to reach. Both host and port are pinned, because grants are
42
+ * prefix-matched and a bare host would also admit every other port on it.
43
+ * Filesystem and child-process stay denied.
44
+ *
45
+ * Opt-in, not default. A denied environment variable is ABSENT from process.env
46
+ * rather than throwing, so an under-granted DATABASE_URL would look like "not
47
+ * configured" instead of "denied". The env list is derived from the shipped
48
+ * bundle and includes the pg driver's own reads (PGSSLMODE, PGCONNECT_TIMEOUT
49
+ * and friends) -- a hand-written list misses those.
50
+ *
51
+ * MINIMUM OAM VERSION
52
+ * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
53
+ * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
54
+ * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
55
+ * behaved as `'pipe'`. This server spawns nothing, so the floor is
56
+ * enforced for consistency across @yawlabs/*-mcp rather than because this
57
+ * launcher was exposed.
58
+ * An older oam is not an error: the launcher falls back to Node and says so on
59
+ * stderr. Pinning the floor here is what makes that fallback automatic.
60
+ *
36
61
  * SELECTION
37
62
  * POSTGRES_MCP_RUNTIME=oam require oam; fail loudly if it is missing
38
63
  * POSTGRES_MCP_RUNTIME=node never use oam
39
64
  * POSTGRES_MCP_RUNTIME=auto prefer oam, silently fall back (default)
65
+ * POSTGRES_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
40
66
  * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
41
67
  */
42
68
 
43
- import { spawn } from "node:child_process";
69
+ import { execFileSync, spawn } from "node:child_process";
44
70
  import { existsSync } from "node:fs";
45
71
  import { constants, homedir } from "node:os";
46
72
  import { delimiter, join } from "node:path";
47
73
  import { fileURLToPath } from "node:url";
48
74
 
75
+ /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
76
+ const OAM_MIN = [0, 9, 0];
77
+
49
78
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
50
79
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
51
80
  // in-process fallback must use the file:// URL. spawn(), conversely, needs a
@@ -90,6 +119,72 @@ function findOam() {
90
119
  return null;
91
120
  }
92
121
 
122
+ /**
123
+ * `oam --version` -> [major, minor, patch], or null when it cannot be read.
124
+ * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
125
+ */
126
+ function oamVersion(cmd) {
127
+ try {
128
+ const out = execFileSync(cmd, ["--version"], {
129
+ encoding: "utf-8",
130
+ stdio: ["ignore", "pipe", "ignore"],
131
+ });
132
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
133
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
134
+ } catch {
135
+ // Not executable, wrong arch, or deleted since the stat. Caller degrades.
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /** True when `v` is at least `min`, comparing major/minor/patch in order. */
141
+ function atLeast(v, min) {
142
+ if (!v) return false;
143
+ for (let i = 0; i < min.length; i++) {
144
+ if (v[i] > min[i]) return true;
145
+ if (v[i] < min[i]) return false;
146
+ }
147
+ return true;
148
+ }
149
+
150
+ /**
151
+ * The `--permission` grant list, or [] when the sandbox is not requested.
152
+ *
153
+ * These are oam's PROCESS-level flags: they belong before the `run` subcommand,
154
+ * not after it. `oam run --permission file.js` is rejected outright, which is a
155
+ * good failure but only because it is loud -- ordering here is load-bearing.
156
+ *
157
+ * Net grants prefix-match `host` for fetch and `host:port` for sockets.
158
+ * A denied environment variable is ABSENT from process.env rather than throwing,
159
+ * so the env list below is derived from what the bundle actually reads; trimming
160
+ * it produces silent misbehaviour, not a clear denial.
161
+ */
162
+ function sandboxFlags() {
163
+ if (process.env.POSTGRES_MCP_SANDBOX !== "1") return [];
164
+
165
+ // Derived, not hardcoded: the only endpoint this server may reach is the one
166
+ // it was configured to reach. Grants are prefix-matched against "host:port"
167
+ // for sockets, so host alone would also admit any other port on that host --
168
+ // pin both. A DSN we cannot parse falls back to a bare grant rather than a
169
+ // broken one, because a wrong narrow grant fails at connect time.
170
+ const dsn = process.env.DATABASE_URL ?? null;
171
+ let netFlag = "--allow-net";
172
+ if (dsn) {
173
+ try {
174
+ const u = new URL(dsn);
175
+ if (u.hostname) netFlag = `--allow-net=${u.hostname}:${u.port || 5432}`;
176
+ } catch {
177
+ // Unparseable DATABASE_URL: leave the grant open. The server will fail on
178
+ // its own connection error, which names the real problem.
179
+ }
180
+ }
181
+
182
+ const env = ["ALLOW_WRITES","DATABASE_URL","NODE_PG_FORCE_NATIVE","PGCONNECT_TIMEOUT","PGSSLMODE","POSTGRES_CONNECTION_TIMEOUT_MS","POSTGRES_MAX_ROWS","POSTGRES_POOL_MAX","POSTGRES_SSL_REJECT_UNAUTHORIZED","POSTGRES_STATEMENT_TIMEOUT_MS","USER","USERNAME"];
183
+
184
+ const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
185
+ return flags;
186
+ }
187
+
93
188
  /** Run the server in THIS process. The zero-overhead fallback. */
94
189
  async function runInProcess() {
95
190
  await import(SERVER_URL.href);
@@ -116,11 +211,30 @@ if (mode === "node") {
116
211
  process.exit(1);
117
212
  }
118
213
  await runInProcess();
214
+ } else if (!atLeast(oamVersion(oam), OAM_MIN)) {
215
+ // Discovery itself stays stat-only; this is the first subprocess, and it
216
+ // runs only once we have already decided to spawn oam anyway. Measured 26ms
217
+ // median (n=12, windows-arm64), paid once per MCP session.
218
+ const min = OAM_MIN.join(".");
219
+ if (mode === "oam") {
220
+ const { writeSync } = await import("node:fs");
221
+ writeSync(
222
+ 2,
223
+ `postgres-mcp: POSTGRES_MCP_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
224
+ `Run \`oam self-update\`, or use POSTGRES_MCP_RUNTIME=node.\n`,
225
+ );
226
+ process.exit(1);
227
+ }
228
+ // auto: an old oam is a reason to prefer Node, not to fail. Say so, because
229
+ // a silent downgrade is how someone keeps running an oam they meant to
230
+ // update. stderr is safe -- MCP frames travel on stdout.
231
+ process.stderr.write(`postgres-mcp: oam at ${oam} is older than ${min}; using Node instead.\n`);
232
+ await runInProcess();
119
233
  } else {
120
234
  // `--` separates oam's own flags from the script's argv. Everything after
121
235
  // it lands in process.argv for the server, so `postgres-mcp version` and
122
236
  // any host-supplied flags survive the hop unchanged.
123
- const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
237
+ const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
124
238
  // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
125
239
  // stdin/stdout is untouched and the host's stdin-close still reaches the
126
240
  // server's shutdown path.
package/dist/index.js CHANGED
@@ -37796,7 +37796,7 @@ function compareVersions(a, b) {
37796
37796
  }
37797
37797
 
37798
37798
  // src/index.ts
37799
- var version2 = true ? "0.9.1" : await readPackageVersion();
37799
+ var version2 = true ? "0.10.0" : await readPackageVersion();
37800
37800
  var subcommand = process.argv[2];
37801
37801
  if (subcommand === "version" || subcommand === "--version") {
37802
37802
  console.log(version2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/postgres-mcp",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",