postgres-mcp-hardened 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # postgres-mcp-hardened
2
+
3
+ A maintained, read-only PostgreSQL MCP server — the drop-in replacement for
4
+ [`@modelcontextprotocol/server-postgres`](https://www.npmjs.com/package/@modelcontextprotocol/server-postgres),
5
+ which was deprecated by its authors and last released in December 2024.
6
+
7
+ Writes are refused by walking the parsed SQL, not by matching strings. Comments, dollar-quoting and
8
+ Unicode tricks do not survive the parse, so they cannot smuggle a statement past the check.
9
+
10
+ ## Replace the deprecated server
11
+
12
+ ```diff
13
+ {
14
+ "mcpServers": {
15
+ "postgres": {
16
+ - "command": "npx",
17
+ - "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"]
18
+ + "command": "npx",
19
+ + "args": ["-y", "postgres-mcp-hardened", "--stdio"],
20
+ + "env": { "DATABASE_URL": "postgres://readonly_user:PASSWORD@localhost:5432/mydb" }
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ The connection string moves from an argument to `DATABASE_URL` on purpose: arguments show up in
27
+ `ps` output and in shell history on a shared machine, and a database password does not belong there.
28
+
29
+ ## What it does differently
30
+
31
+ - **Read-only is enforced twice.** The validator rejects anything that is not a read, and the
32
+ session runs in a `READ ONLY` transaction — so a gap in the first layer is not a breach.
33
+ - **Statement timeout and row caps** are set server-side, so a careless question cannot pin your
34
+ database or drag a million rows into a model's context.
35
+ - **Every query is written to an audit log** chained by hash, which survives a restart and makes
36
+ a deleted or truncated tail detectable.
37
+ - **Optional OAuth (RS256)** with audience and issuer enforced, for running it as a shared HTTP
38
+ endpoint rather than a local process.
39
+ - **Signed releases** — every artefact carries a Sigstore signature and a SHA-256, and this package
40
+ refuses to install a binary whose checksum does not match the one recorded at publish time.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ npx -y postgres-mcp-hardened --stdio # no install
46
+ npm install -g postgres-mcp-hardened # or keep it around
47
+ ```
48
+
49
+ The package fetches a prebuilt binary for your platform from the matching GitHub release:
50
+ Linux (x64, arm64), macOS (Intel, Apple Silicon) and Windows (x64). Alpine/musl is not among them —
51
+ the Linux builds link against glibc; use the container image `ghcr.io/eszetael/postgres-mcp-hardened`
52
+ or build from source with `cargo build --release` in a clone.
53
+
54
+ Full documentation, configuration reference and the security model:
55
+ **https://github.com/Eszetael/postgres-mcp-hardened**
56
+
57
+ MIT licensed.
package/bin/cli.js ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // The launcher an MCP client actually spawns. It hands over to the native binary with execve-like
5
+ // semantics: same stdio, same exit code, no wrapper process interpreting the protocol.
6
+ //
7
+ // It also downloads the binary if it is missing. That is deliberate belt-and-braces: `postinstall`
8
+ // normally does it at install time, but plenty of environments run `npm install --ignore-scripts`
9
+ // (corporate policy, most CI defaults), and there the postinstall never fires. Without this branch
10
+ // those users would get "command not found" from a package that installed successfully — the kind
11
+ // of failure people do not report, they just leave.
12
+
13
+ const { spawn } = require('node:child_process');
14
+ const fs = require('node:fs');
15
+ const { ensureBinary, binaryPath } = require('../lib/install.js');
16
+
17
+ // Every flag the server understands, top-level and inside `--print-setup-sql`. Kept here because
18
+ // the server itself scans for the flags it knows and ignores the rest — so `--stdi` does not fail,
19
+ // it starts an HTTP listener instead of a stdio server. For someone wiring this into an MCP client
20
+ // that is a silent hang; on a shared machine it is an unintended open port. A test asserts this
21
+ // list against the Rust sources, so a new flag upstream breaks the build rather than a user's day.
22
+ const KNOWN = new Set([
23
+ '--stdio', '--validate', '--canon', '--fuzz', '--verify-audit', '--expect-last',
24
+ '--print-setup-sql', '--role', '--schemas', '--tables', '--redact', '--database', '--owner',
25
+ ]);
26
+
27
+ const USAGE = `postgres-mcp-hardened — read-only PostgreSQL MCP server
28
+
29
+ postgres-mcp-hardened --stdio speak MCP over stdin/stdout (what an MCP client spawns)
30
+ postgres-mcp-hardened serve Streamable HTTP on MCP_ADDR (default 127.0.0.1:8080)
31
+
32
+ --validate <sql> print the validator's verdict for one statement
33
+ --canon <sql> print the text that would actually reach the database
34
+ --verify-audit <path> [--expect-last <hash>] check the audit log's hash chain
35
+ --print-setup-sql [--role R] [--schemas S] [--tables T] [--redact C] [--database D] [--owner O]
36
+ print the SQL that creates a least-privilege role
37
+ --fuzz [iterations] [seed] deterministic validator fuzz; exits 1 on a violation
38
+
39
+ -h, --help this text
40
+ -V, --version package version
41
+
42
+ Configuration is environment-driven; DATABASE_URL is required to serve.
43
+ Full reference: https://github.com/Eszetael/postgres-mcp-hardened`;
44
+
45
+ function guard(argv) {
46
+ if (argv.includes('-h') || argv.includes('--help')) {
47
+ process.stdout.write(USAGE + '\n');
48
+ return { exit: 0 };
49
+ }
50
+ if (argv.includes('-V') || argv.includes('--version')) {
51
+ process.stdout.write(require('../package.json').version + '\n');
52
+ return { exit: 0 };
53
+ }
54
+ const unknown = argv.filter((a) => a.startsWith('--') && !KNOWN.has(a));
55
+ if (unknown.length) {
56
+ process.stderr.write(
57
+ `postgres-mcp-hardened: unknown option ${unknown.join(', ')}\n` +
58
+ `The server ignores options it does not recognise, so a typo in --stdio would have started ` +
59
+ `a network listener instead of a stdio server. Refusing.\n\n${USAGE}\n`);
60
+ return { exit: 2 };
61
+ }
62
+ return null;
63
+ }
64
+
65
+ async function main() {
66
+ const stop = guard(process.argv.slice(2));
67
+ if (stop) process.exit(stop.exit);
68
+
69
+ let bin = binaryPath();
70
+ if (!fs.existsSync(bin)) {
71
+ // stdio is the MCP transport: one stray byte on stdout and the client sees a malformed frame.
72
+ // Progress goes to stderr, which clients log and ignore.
73
+ bin = await ensureBinary();
74
+ }
75
+
76
+ const child = spawn(bin, process.argv.slice(2), { stdio: 'inherit' });
77
+
78
+ // Signals are forwarded rather than left to kill the launcher: an MCP client shutting a server
79
+ // down sends SIGTERM to the process it spawned, and if that is us, the native process would be
80
+ // orphaned and keep its database connections open.
81
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
82
+ process.on(sig, () => { try { child.kill(sig); } catch { /* already gone */ } });
83
+ }
84
+
85
+ child.on('error', (e) => {
86
+ process.stderr.write(`postgres-mcp-hardened: cannot start ${bin}\n${e.message}\n`);
87
+ process.exit(127);
88
+ });
89
+ // A process killed by a signal has no exit code; report it the way a shell does, so a supervisor
90
+ // can tell "crashed" from "exited non-zero".
91
+ child.on('exit', (code, signal) => process.exit(signal ? 128 + osSignalNumber(signal) : code ?? 0));
92
+ }
93
+
94
+ function osSignalNumber(sig) {
95
+ return { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 }[sig] ?? 0;
96
+ }
97
+
98
+ main().catch((e) => {
99
+ process.stderr.write(`postgres-mcp-hardened: ${e.message}\n`);
100
+ process.exit(1);
101
+ });
package/checksums.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "postgres-mcp-hardened-aarch64-apple-darwin.tar.gz": "7d1dedc7f46a34f3863a8b3ab7dd34d6eba75e086f567e0874e2dae657d925e8",
3
+ "postgres-mcp-hardened-aarch64-unknown-linux-gnu.tar.gz": "1ce5543a4feaaedd13536a75895f29cf9fa9b498704e1c1a5b157f0b85ce570c",
4
+ "postgres-mcp-hardened-x86_64-apple-darwin.tar.gz": "5747fa27c728f35de77467bd9cd91c3f7f8598983bc3dfd8365bd222fd20d823",
5
+ "postgres-mcp-hardened-x86_64-unknown-linux-gnu.tar.gz": "5853be99322d5bdcbcc35d7a5cd6f5a4f687f336c1db68daea27a4eb638fcf2e",
6
+ "postgres-mcp-hardened-x86_64-pc-windows-msvc.zip": "f8ccaf6d08a6e16b4bdefb2b6fd81d1255b3fac1cdb5587921d593eca93ed6ee"
7
+ }
package/lib/install.js ADDED
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ // Fetches the prebuilt binary for this platform from the GitHub Release that matches this package
4
+ // version, verifies it against a checksum baked in at publish time, and unpacks it next to this file.
5
+ //
6
+ // Why the checksum is IN the package rather than fetched alongside the download: a checksum served
7
+ // from the same place as the artefact proves the transfer was not corrupted and nothing else. The
8
+ // list in `checksums.json` is written by the release workflow from the artefacts it just built, so
9
+ // it travels with npm's own integrity guarantee. If the two disagree, the download is wrong — and
10
+ // for a tool whose whole selling point is refusing unsafe input, "run it anyway" is not an option.
11
+
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+ const crypto = require('node:crypto');
16
+ const { execFileSync } = require('node:child_process');
17
+
18
+ const REPO = 'Eszetael/postgres-mcp-hardened';
19
+ const BIN = 'postgres-mcp-hardened';
20
+
21
+ // npm's platform names on the left, Rust target triples on the right. Kept explicit rather than
22
+ // assembled from parts: a wrong triple would download an archive that unpacks fine and then dies
23
+ // with "Exec format error", which is a far worse message than "unsupported platform".
24
+ const TARGETS = {
25
+ 'linux-x64': 'x86_64-unknown-linux-gnu',
26
+ 'linux-arm64': 'aarch64-unknown-linux-gnu',
27
+ 'darwin-x64': 'x86_64-apple-darwin',
28
+ 'darwin-arm64': 'aarch64-apple-darwin',
29
+ 'win32-x64': 'x86_64-pc-windows-msvc',
30
+ };
31
+
32
+ const key = () => `${process.platform}-${process.arch}`;
33
+
34
+ function binaryPath() {
35
+ const exe = process.platform === 'win32' ? `${BIN}.exe` : BIN;
36
+ return path.join(__dirname, '..', 'vendor', exe);
37
+ }
38
+
39
+ function targetOrExplain() {
40
+ const t = TARGETS[key()];
41
+ if (t) return t;
42
+ const supported = Object.keys(TARGETS).join(', ');
43
+ throw new Error(
44
+ `No prebuilt binary for ${key()}. Supported: ${supported}.\n` +
45
+ `Build from source instead: cargo install --git https://github.com/${REPO}\n` +
46
+ `(Alpine/musl is not in that list either — the Linux builds are glibc. Use the container image: ` +
47
+ `ghcr.io/${REPO.toLowerCase()})`
48
+ );
49
+ }
50
+
51
+ function expectedChecksum(archive) {
52
+ let sums;
53
+ try {
54
+ sums = require('../checksums.json');
55
+ } catch {
56
+ sums = null;
57
+ }
58
+ const sum = sums && sums[archive];
59
+ if (!sum) {
60
+ // Fail closed. An empty checksum list means the package was published without the release
61
+ // workflow filling it in — that is a broken publish, and silently trusting the download would
62
+ // turn one mistake into a supply-chain hole that nobody would ever notice.
63
+ throw new Error(
64
+ `No checksum recorded for ${archive}. This package was published incorrectly; ` +
65
+ `refusing to install an unverified binary. Please open an issue at https://github.com/${REPO}/issues`
66
+ );
67
+ }
68
+ return sum;
69
+ }
70
+
71
+ async function download(url) {
72
+ const res = await fetch(url, { redirect: 'follow' });
73
+ if (!res.ok) {
74
+ throw new Error(`Download failed: ${res.status} ${res.statusText}\n ${url}`);
75
+ }
76
+ return Buffer.from(await res.arrayBuffer());
77
+ }
78
+
79
+ function unpack(archivePath, into) {
80
+ fs.mkdirSync(into, { recursive: true });
81
+ try {
82
+ // bsdtar ships with macOS, every Linux, and Windows 10+ — and it reads .zip as well as .tar.gz,
83
+ // so one command covers all five targets.
84
+ execFileSync('tar', ['-xf', archivePath, '-C', into], { stdio: 'ignore' });
85
+ } catch (e) {
86
+ if (process.platform !== 'win32') throw e;
87
+ execFileSync('powershell', ['-NoProfile', '-Command',
88
+ `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${into}' -Force`],
89
+ { stdio: 'ignore' });
90
+ }
91
+ }
92
+
93
+ async function ensureBinary({ quiet = false } = {}) {
94
+ const dest = binaryPath();
95
+ if (fs.existsSync(dest)) return dest;
96
+
97
+ const target = targetOrExplain();
98
+ const ext = process.platform === 'win32' ? 'zip' : 'tar.gz';
99
+ const archive = `${BIN}-${target}.${ext}`;
100
+ const version = require('../package.json').version;
101
+ const url = `https://github.com/${REPO}/releases/download/v${version}/${archive}`;
102
+
103
+ // Ask for the checksum BEFORE spending the bandwidth. If the list is missing an entry we are
104
+ // going to refuse anyway, and refusing after a 10 MB download only makes the failure slower.
105
+ const want = expectedChecksum(archive);
106
+
107
+ if (!quiet) process.stderr.write(`postgres-mcp-hardened: fetching ${archive} (v${version})\n`);
108
+ const buf = await download(url);
109
+
110
+ const got = crypto.createHash('sha256').update(buf).digest('hex');
111
+ if (got !== want) {
112
+ throw new Error(
113
+ `Checksum mismatch for ${archive}\n expected ${want}\n got ${got}\n` +
114
+ `Refusing to install. Report this at https://github.com/${REPO}/issues`
115
+ );
116
+ }
117
+
118
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pmh-'));
119
+ const archivePath = path.join(tmp, archive);
120
+ fs.writeFileSync(archivePath, buf);
121
+ unpack(archivePath, path.dirname(dest));
122
+ fs.rmSync(tmp, { recursive: true, force: true });
123
+
124
+ if (!fs.existsSync(dest)) {
125
+ throw new Error(`Archive ${archive} did not contain ${path.basename(dest)}`);
126
+ }
127
+ if (process.platform !== 'win32') fs.chmodSync(dest, 0o755);
128
+ return dest;
129
+ }
130
+
131
+ module.exports = {
132
+ ensureBinary, binaryPath, TARGETS, REPO, BIN,
133
+ // Exported for the tests: both are refusal paths, and a refusal that is never exercised is a
134
+ // refusal nobody knows still works.
135
+ targetOrExplain, expectedChecksum, key,
136
+ };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "postgres-mcp-hardened",
3
+ "version": "0.1.1",
4
+ "description": "Secure read-only PostgreSQL MCP server in Rust — a maintained alternative to the deprecated @modelcontextprotocol/server-postgres. Blocks writes at the AST, not with regexes.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "postgres",
9
+ "postgresql",
10
+ "mcp-server",
11
+ "read-only",
12
+ "sql",
13
+ "ai",
14
+ "llm",
15
+ "claude",
16
+ "cursor",
17
+ "rust"
18
+ ],
19
+ "homepage": "https://github.com/Eszetael/postgres-mcp-hardened#readme",
20
+ "bugs": "https://github.com/Eszetael/postgres-mcp-hardened/issues",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/Eszetael/postgres-mcp-hardened.git",
24
+ "directory": "npm"
25
+ },
26
+ "license": "MIT",
27
+ "type": "commonjs",
28
+ "bin": {
29
+ "postgres-mcp-hardened": "bin/cli.js"
30
+ },
31
+ "files": [
32
+ "bin/",
33
+ "lib/",
34
+ "scripts/",
35
+ "checksums.json",
36
+ "README.md"
37
+ ],
38
+ "scripts": {
39
+ "postinstall": "node scripts/postinstall.js",
40
+ "test": "node scripts/run-tests.js"
41
+ },
42
+ "engines": {
43
+ "node": ">=18.17"
44
+ },
45
+ "os": [
46
+ "linux",
47
+ "darwin",
48
+ "win32"
49
+ ],
50
+ "cpu": [
51
+ "x64",
52
+ "arm64"
53
+ ]
54
+ }
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ // Fetches the binary at install time so the first `npx` run is instant — an MCP client spawns the
4
+ // server and waits for a handshake, and a 10 MB download inside that window looks like a hang.
5
+ //
6
+ // It must never fail the install. Offline machines, proxies and `--ignore-scripts` all exist, and
7
+ // the launcher downloads on demand anyway; turning a recoverable situation into a failed
8
+ // `npm install` would be worse than the delay it avoids. So: explain, and exit 0.
9
+
10
+ const { ensureBinary } = require('../lib/install.js');
11
+
12
+ ensureBinary().catch((e) => {
13
+ process.stderr.write(
14
+ `postgres-mcp-hardened: could not fetch the binary now (${e.message.split('\n')[0]}).\n` +
15
+ `It will be fetched on first run instead.\n`
16
+ );
17
+ process.exit(0);
18
+ });
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Uruchamia testy opakowania i ODMAWIA, gdy nie ma czego uruchomić.
5
+ //
6
+ // Dwa błędy naraz, oba znalezione 7.08 i oba tego samego rodzaju — ścieżka, której nikt nie wykonał.
7
+ //
8
+ // 1. `npm test` było ustawione na `node --test test/`. Na Node 22 ta forma nie uruchamia katalogu,
9
+ // tylko próbuje wczytać go jako moduł i pada z MODULE_NOT_FOUND. Czyli udokumentowana komenda
10
+ // projektu nie działała, podczas gdy same testy przechodziły 9/9 uruchomione na pliku. CI
11
+ // wywoływało dokładnie tę zepsutą formę i nigdy się o tym nie dowiedziało, bo zadanie `npm`
12
+ // zależy od `build`, a `build` padał od 28.07 na uprawnieniach.
13
+ //
14
+ // 2. Oczywista poprawka — samo `node --test` — ma gorszą wadę: w katalogu BEZ testów kończy się
15
+ // kodem 0. Zero testów wygląda wtedy identycznie jak komplet zdanych. Sprawdzone wprost.
16
+ //
17
+ // Stąd ten plik: wyszukaj pliki testów, odmów przy zerze, a dopiero potem uruchom. Wyszukiwanie
18
+ // jest rekurencyjne, więc nowy podkatalog nie wypadnie po cichu z pakietu.
19
+
20
+ const { readdirSync, statSync } = require('node:fs');
21
+ const { join, relative } = require('node:path');
22
+ const { spawnSync } = require('node:child_process');
23
+
24
+ const ROOT = join(__dirname, '..');
25
+ const TEST_DIR = join(ROOT, 'test');
26
+
27
+ function collect(dir) {
28
+ let out = [];
29
+ for (const name of readdirSync(dir)) {
30
+ const p = join(dir, name);
31
+ if (statSync(p).isDirectory()) out = out.concat(collect(p));
32
+ else if (/\.test\.(c|m)?js$/.test(name)) out.push(p);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ let files = [];
38
+ try {
39
+ files = collect(TEST_DIR);
40
+ } catch (err) {
41
+ console.error(`ODMOWA: nie da się odczytać ${relative(ROOT, TEST_DIR)} — ${err.message}`);
42
+ process.exit(3);
43
+ }
44
+
45
+ if (files.length === 0) {
46
+ console.error('ODMOWA: nie znaleziono ani jednego pliku *.test.js.');
47
+ console.error('Zero testów nie jest sukcesem, a `node --test` w pustym katalogu kończy się zerem.');
48
+ process.exit(3);
49
+ }
50
+
51
+ console.log(`uruchamiam ${files.length} plik(ów) testów:`);
52
+ for (const f of files) console.log(` ${relative(ROOT, f)}`);
53
+
54
+ const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit', cwd: ROOT });
55
+ process.exit(r.status === null ? 1 : r.status);