Sign In

@yawlabs/postgres-mcp

Package Overview
Dependencies
Maintainers
1
Versions
36
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@yawlabs/postgres-mcp - npm Package Compare versions

Comparing version
0.9.1
to
0.10.0
+116
-2
bin/postgres-mcp.mjs

@@ -36,2 +36,27 @@ #!/usr/bin/env node

*
* THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
* `POSTGRES_MCP_SANDBOX=1` runs the server under oam's permission model.
*
* The database host is not knowable ahead of time, so the net grant is DERIVED
* from DATABASE_URL at launch: the one endpoint this server may reach is the one
* it was configured to reach. Both host and port are pinned, because grants are
* prefix-matched and a bare host would also admit every other port on it.
* Filesystem and child-process stay denied.
*
* Opt-in, not default. A denied environment variable is ABSENT from process.env
* rather than throwing, so an under-granted DATABASE_URL would look like "not
* configured" instead of "denied". The env list is derived from the shipped
* bundle and includes the pg driver's own reads (PGSSLMODE, PGCONNECT_TIMEOUT
* and friends) -- a hand-written list misses those.
*
* MINIMUM OAM VERSION
* 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
* `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
* `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
* behaved as `'pipe'`. This server spawns nothing, so the floor is
* enforced for consistency across @yawlabs/*-mcp rather than because this
* launcher was exposed.
* An older oam is not an error: the launcher falls back to Node and says so on
* stderr. Pinning the floor here is what makes that fallback automatic.
*
* SELECTION

@@ -41,6 +66,7 @@ * POSTGRES_MCP_RUNTIME=oam require oam; fail loudly if it is missing

* POSTGRES_MCP_RUNTIME=auto prefer oam, silently fall back (default)
* POSTGRES_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
* OAM_BIN=/path/to/oam explicit binary, checked before any discovery
*/
import { spawn } from "node:child_process";
import { execFileSync, spawn } from "node:child_process";
import { existsSync } from "node:fs";

@@ -51,2 +77,5 @@ import { constants, homedir } from "node:os";

/** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
const OAM_MIN = [0, 9, 0];
// Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path

@@ -96,2 +125,68 @@ // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the

/**
* `oam --version` -> [major, minor, patch], or null when it cannot be read.
* A pre-release suffix (0.9.0-rc.1) truncates to its base version.
*/
function oamVersion(cmd) {
try {
const out = execFileSync(cmd, ["--version"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
});
const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
} catch {
// Not executable, wrong arch, or deleted since the stat. Caller degrades.
return null;
}
}
/** True when `v` is at least `min`, comparing major/minor/patch in order. */
function atLeast(v, min) {
if (!v) return false;
for (let i = 0; i < min.length; i++) {
if (v[i] > min[i]) return true;
if (v[i] < min[i]) return false;
}
return true;
}
/**
* The `--permission` grant list, or [] when the sandbox is not requested.
*
* These are oam's PROCESS-level flags: they belong before the `run` subcommand,
* not after it. `oam run --permission file.js` is rejected outright, which is a
* good failure but only because it is loud -- ordering here is load-bearing.
*
* Net grants prefix-match `host` for fetch and `host:port` for sockets.
* A denied environment variable is ABSENT from process.env rather than throwing,
* so the env list below is derived from what the bundle actually reads; trimming
* it produces silent misbehaviour, not a clear denial.
*/
function sandboxFlags() {
if (process.env.POSTGRES_MCP_SANDBOX !== "1") return [];
// Derived, not hardcoded: the only endpoint this server may reach is the one
// it was configured to reach. Grants are prefix-matched against "host:port"
// for sockets, so host alone would also admit any other port on that host --
// pin both. A DSN we cannot parse falls back to a bare grant rather than a
// broken one, because a wrong narrow grant fails at connect time.
const dsn = process.env.DATABASE_URL ?? null;
let netFlag = "--allow-net";
if (dsn) {
try {
const u = new URL(dsn);
if (u.hostname) netFlag = `--allow-net=${u.hostname}:${u.port || 5432}`;
} catch {
// Unparseable DATABASE_URL: leave the grant open. The server will fail on
// its own connection error, which names the real problem.
}
}
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"];
const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
return flags;
}
/** Run the server in THIS process. The zero-overhead fallback. */

@@ -123,2 +218,21 @@ async function runInProcess() {

await runInProcess();
} else if (!atLeast(oamVersion(oam), OAM_MIN)) {
// Discovery itself stays stat-only; this is the first subprocess, and it
// runs only once we have already decided to spawn oam anyway. Measured 26ms
// median (n=12, windows-arm64), paid once per MCP session.
const min = OAM_MIN.join(".");
if (mode === "oam") {
const { writeSync } = await import("node:fs");
writeSync(
2,
`postgres-mcp: POSTGRES_MCP_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
`Run \`oam self-update\`, or use POSTGRES_MCP_RUNTIME=node.\n`,
);
process.exit(1);
}
// auto: an old oam is a reason to prefer Node, not to fail. Say so, because
// a silent downgrade is how someone keeps running an oam they meant to
// update. stderr is safe -- MCP frames travel on stdout.
process.stderr.write(`postgres-mcp: oam at ${oam} is older than ${min}; using Node instead.\n`);
await runInProcess();
} else {

@@ -128,3 +242,3 @@ // `--` separates oam's own flags from the script's argv. Everything after

// any host-supplied flags survive the hop unchanged.
const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on

@@ -131,0 +245,0 @@ // stdin/stdout is untouched and the host's stdin-close still reaches the

@@ -10,2 +10,40 @@ # Changelog

## [0.10.0] - 2026-08-08
### Added
- **An opt-in `--permission` sandbox under oam**, via `POSTGRES_MCP_SANDBOX=1`.
The network grant is derived from `DATABASE_URL` at launch rather than
hardcoded, so the one endpoint the server may reach is the one it was
configured to reach. Host and port are both pinned, because oam matches grants
by prefix and a bare host would also admit every other port on it. Filesystem
and child-process are denied outright.
Opt-in rather than default because a wrong grant does not fail loudly: oam
denies a non-granted environment variable by making it **absent** from
`process.env` rather than throwing, so an under-granted `DATABASE_URL` would
read as "not configured" instead of "denied". The environment allow-list is
derived from what the shipped bundle actually reads, which is why it includes
the pg driver's own lookups (`PGSSLMODE`, `PGCONNECT_TIMEOUT` and friends) that
a hand-written list would have missed.
### Changed
- **oam 0.9.0 is now the minimum**, enforced in `bin/postgres-mcp.mjs`. Older
releases ran `child_process.execFile` arguments through a shell, accepted
`exec`'s `timeout` and ignored it, truncated `spawnSync` at `maxBuffer` while
reporting success, and treated `stdio: 'inherit'` as `'pipe'`. This server
spawns nothing, so the floor is enforced for consistency across
`@yawlabs/*-mcp` rather than because this launcher was exposed. An older oam is
not an error: the launcher falls back to Node and says so on stderr, and
`POSTGRES_MCP_RUNTIME=oam` turns that into a hard error.
### Fixed
- **`release.sh` aborted instead of releasing when `[Unreleased]` was empty.**
The body extraction pipes through `grep -v` to drop blank lines, and `grep`
exits non-zero when it matches nothing — so under `set -e` an empty section
killed the script at that line, and the `warn` branch written to handle
exactly that case could never run.
## [0.9.1] - 2026-08-07

@@ -12,0 +50,0 @@

+1
-1
{
"name": "@yawlabs/postgres-mcp",
"version": "0.9.1",
"version": "0.10.0",
"mcpName": "io.github.YawLabs/postgres-mcp",

@@ -5,0 +5,0 @@ "description": "PostgreSQL MCP server - query, schema introspection, explain, and health checks for AI assistants",

Sorry, the diff of this file is too big to display