@yawlabs/postgres-mcp 0.3.1 → 0.3.3
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 +24 -9
- package/dist/index.js +40 -14
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -2,23 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@yawlabs/postgres-mcp)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/YawLabs/postgres-mcp/actions/workflows/ci.yml) [](https://github.com/YawLabs/postgres-mcp/actions/workflows/release.yml)
|
|
6
5
|
|
|
7
6
|
**Query a PostgreSQL database from Claude Code, Cursor, and any MCP client.** Read-only by default — writes opt in via a single env var — so an agent can't silently drop your tables.
|
|
8
7
|
|
|
9
8
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
10
9
|
|
|
11
|
-
##
|
|
10
|
+
## Backstory
|
|
11
|
+
|
|
12
|
+
Anthropic's reference Postgres MCP server, `@modelcontextprotocol/server-postgres`, was [archived in May 2025](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) and [marked deprecated on npm](https://www.npmjs.com/package/@modelcontextprotocol/server-postgres) in July 2025. Anthropic has not shipped a replacement. Despite the deprecation, the last published version (v0.6.2) is still pulled ~20,000 times per week — a lot of agents are pointed at an unmaintained package.
|
|
13
|
+
|
|
14
|
+
That unmaintained package also has a known, [publicly documented stacked-query SQL injection](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/) (Datadog Security Labs) that bypasses its `BEGIN READ ONLY` wrapper with input like `COMMIT; DROP SCHEMA public CASCADE;`. It has never been patched at npm.
|
|
15
|
+
|
|
16
|
+
A handful of community forks have appeared, but each fills a narrow slice:
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
- [`@zeddotdev/postgres-context-server`](https://www.npmjs.com/package/@zeddotdev/postgres-context-server) — Zed's fork, primarily a security patch on the original shape.
|
|
19
|
+
- **Postgres MCP Pro** (Crystal DBA) — focused on index tuning and hypothetical-index / buffer-cache diagnostics.
|
|
20
|
+
- **AWS Labs Postgres MCP** — tied to Aurora / RDS Data API + Secrets Manager.
|
|
21
|
+
|
|
22
|
+
None of them position themselves as a general-purpose daily driver you'd hand to Claude Code or Cursor against an arbitrary Postgres: modern introspection, perf helpers, role/privilege awareness, and a write-safety posture out of the box. That's the gap `@yawlabs/postgres-mcp` fills.
|
|
23
|
+
|
|
24
|
+
## Why this one?
|
|
14
25
|
|
|
15
|
-
- **Read-only by default** — user SQL runs in a `BEGIN READ ONLY` transaction, so postgres itself (not string parsing) blocks writes. Opt in
|
|
16
|
-
- **
|
|
17
|
-
-
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
26
|
+
- **Read-only by default** — user SQL runs in a `BEGIN READ ONLY` transaction, so postgres itself (not string parsing) blocks writes. Opt in with `ALLOW_WRITES=1`.
|
|
27
|
+
- **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.
|
|
28
|
+
- **Parameterized queries** — `pg_query` takes a `params` array for `$1`, `$2`, etc. No string-interpolated SQL in our code path.
|
|
29
|
+
- **Written from scratch, actively maintained** — not a fork of the deprecated code. Unit + integration tests (`npm test`, `npm run test:integration`) run against a real Postgres; releases cut via `release.sh`.
|
|
30
|
+
- **Schema introspection built in** — `pg_list_schemas`, `pg_list_tables`, `pg_describe_table` return columns, primary keys, foreign keys, and indexes without the agent having to remember `pg_catalog` joins.
|
|
31
|
+
- **`EXPLAIN` as a first-class tool** — text or JSON format, with optional `ANALYZE`. ANALYZE for non-SELECT statements requires `ALLOW_WRITES=1` and always rolls back, so the plan is real but the write doesn't persist.
|
|
32
|
+
- **Perf diagnostics the deprecated server never had** — `pg_top_queries` (from `pg_stat_statements`), `pg_seq_scan_tables`, `pg_unused_indexes`, `pg_table_bloat`, `pg_inspect_locks`, `pg_replication_status`. Answer "why is this slow?" in one tool call.
|
|
33
|
+
- **Health snapshot** — `pg_health` returns version, db size, connection counts, and the 10 longest-running active queries in one call.
|
|
34
|
+
- **Role and privilege awareness** — `pg_list_roles` and `pg_table_privileges` for the common "who can touch what?" questions.
|
|
35
|
+
- **Instant startup** — ships as a single bundled file with zero runtime dependencies. No multi-minute `node_modules` install on every `npx` cold start.
|
|
20
36
|
- **Result truncation** — large result sets are capped at `POSTGRES_MAX_ROWS` (default 1000) with a `truncated: true` flag, so a stray `SELECT * FROM events` doesn't blow out the model context.
|
|
21
|
-
- **Parameterized queries** — `pg_query` accepts a `params` array for `$1`, `$2`, etc. No string-interpolated SQL.
|
|
22
37
|
|
|
23
38
|
## Quick start
|
|
24
39
|
|
package/dist/index.js
CHANGED
|
@@ -35324,7 +35324,7 @@ async function runReadOnly(sql, params = []) {
|
|
|
35324
35324
|
const maxRows = getMaxRows();
|
|
35325
35325
|
try {
|
|
35326
35326
|
await client.query("BEGIN READ ONLY");
|
|
35327
|
-
const result = await client.query(sql, params);
|
|
35327
|
+
const result = await client.query({ text: sql, values: params, queryMode: "extended" });
|
|
35328
35328
|
await client.query("ROLLBACK");
|
|
35329
35329
|
return { ok: true, data: toQueryResult(result, maxRows) };
|
|
35330
35330
|
} catch (err) {
|
|
@@ -35348,7 +35348,7 @@ async function runReadWrite(sql, params = []) {
|
|
|
35348
35348
|
const maxRows = getMaxRows();
|
|
35349
35349
|
try {
|
|
35350
35350
|
await client.query("BEGIN");
|
|
35351
|
-
const result = await client.query(sql, params);
|
|
35351
|
+
const result = await client.query({ text: sql, values: params, queryMode: "extended" });
|
|
35352
35352
|
await client.query("COMMIT");
|
|
35353
35353
|
return { ok: true, data: toQueryResult(result, maxRows) };
|
|
35354
35354
|
} catch (err) {
|
|
@@ -35361,6 +35361,30 @@ async function runReadWrite(sql, params = []) {
|
|
|
35361
35361
|
client.release();
|
|
35362
35362
|
}
|
|
35363
35363
|
}
|
|
35364
|
+
async function runReadWriteRollback(sql, params = []) {
|
|
35365
|
+
if (!isWritesAllowed()) {
|
|
35366
|
+
return {
|
|
35367
|
+
ok: false,
|
|
35368
|
+
error: "Write blocked: ALLOW_WRITES is not set. Set ALLOW_WRITES=1 in the MCP server env to enable DML/DDL."
|
|
35369
|
+
};
|
|
35370
|
+
}
|
|
35371
|
+
const client = await getPool().connect();
|
|
35372
|
+
const maxRows = getMaxRows();
|
|
35373
|
+
try {
|
|
35374
|
+
await client.query("BEGIN");
|
|
35375
|
+
const result = await client.query({ text: sql, values: params, queryMode: "extended" });
|
|
35376
|
+
await client.query("ROLLBACK");
|
|
35377
|
+
return { ok: true, data: toQueryResult(result, maxRows) };
|
|
35378
|
+
} catch (err) {
|
|
35379
|
+
try {
|
|
35380
|
+
await client.query("ROLLBACK");
|
|
35381
|
+
} catch {
|
|
35382
|
+
}
|
|
35383
|
+
return { ok: false, error: formatPgError(err) };
|
|
35384
|
+
} finally {
|
|
35385
|
+
client.release();
|
|
35386
|
+
}
|
|
35387
|
+
}
|
|
35364
35388
|
async function runInternal(sql, params = []) {
|
|
35365
35389
|
try {
|
|
35366
35390
|
const result = await getPool().query(sql, params);
|
|
@@ -35637,14 +35661,16 @@ var adminTools = [
|
|
|
35637
35661
|
}
|
|
35638
35662
|
];
|
|
35639
35663
|
|
|
35640
|
-
// src/tools/
|
|
35664
|
+
// src/tools/params.ts
|
|
35641
35665
|
var paramValue = external_exports3.lazy(
|
|
35642
35666
|
() => external_exports3.union([external_exports3.string(), external_exports3.number(), external_exports3.boolean(), external_exports3.null(), external_exports3.array(paramValue), external_exports3.record(external_exports3.string(), paramValue)])
|
|
35643
35667
|
);
|
|
35668
|
+
|
|
35669
|
+
// src/tools/explain.ts
|
|
35644
35670
|
var explainTools = [
|
|
35645
35671
|
{
|
|
35646
35672
|
name: "pg_explain",
|
|
35647
|
-
description: "Get the query plan for a SQL statement. By default, this uses plain EXPLAIN (no execution). Set `analyze: true` to run the query with EXPLAIN ANALYZE \u2014 for non-SELECT statements, ALLOW_WRITES=1 is required (since ANALYZE actually executes the statement). Format is `text` (default) or `json`. Pass the raw SQL (not an EXPLAIN-prefixed statement).",
|
|
35673
|
+
description: "Get the query plan for a SQL statement. By default, this uses plain EXPLAIN (no execution). Set `analyze: true` to run the query with EXPLAIN ANALYZE \u2014 for non-SELECT statements, ALLOW_WRITES=1 is required (since ANALYZE actually executes the statement). Writes executed during EXPLAIN ANALYZE are always rolled back, so you can inspect a plan for an INSERT/UPDATE/DELETE without persisting the mutation. Format is `text` (default) or `json`. Pass the raw SQL (not an EXPLAIN-prefixed statement).",
|
|
35648
35674
|
annotations: {
|
|
35649
35675
|
title: "Explain query plan",
|
|
35650
35676
|
readOnlyHint: false,
|
|
@@ -35670,7 +35696,7 @@ var explainTools = [
|
|
|
35670
35696
|
if (analyze) flags.push("ANALYZE");
|
|
35671
35697
|
if (format === "json") flags.push("FORMAT JSON");
|
|
35672
35698
|
const explainSql = flags.length > 0 ? `EXPLAIN (${flags.join(", ")}) ${sql}` : `EXPLAIN ${sql}`;
|
|
35673
|
-
const result = analyze && isWritesAllowed() ? await
|
|
35699
|
+
const result = analyze && isWritesAllowed() ? await runReadWriteRollback(explainSql, params ?? []) : await runReadOnly(explainSql, params ?? []);
|
|
35674
35700
|
if (!result.ok) return result;
|
|
35675
35701
|
const data = result.data;
|
|
35676
35702
|
const rows = data?.rows ?? [];
|
|
@@ -35740,7 +35766,8 @@ var healthTools = [
|
|
|
35740
35766
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
35741
35767
|
WHERE c.relkind IN ('r', 'p')
|
|
35742
35768
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
35743
|
-
AND n.nspname NOT LIKE 'pg_toast%'
|
|
35769
|
+
AND n.nspname NOT LIKE 'pg_toast%'
|
|
35770
|
+
AND n.nspname NOT LIKE 'pg_temp_%'`
|
|
35744
35771
|
)
|
|
35745
35772
|
]);
|
|
35746
35773
|
if (!versionRes.ok) return versionRes;
|
|
@@ -35760,9 +35787,6 @@ var healthTools = [
|
|
|
35760
35787
|
];
|
|
35761
35788
|
|
|
35762
35789
|
// src/tools/query.ts
|
|
35763
|
-
var paramValue2 = external_exports3.lazy(
|
|
35764
|
-
() => external_exports3.union([external_exports3.string(), external_exports3.number(), external_exports3.boolean(), external_exports3.null(), external_exports3.array(paramValue2), external_exports3.record(external_exports3.string(), paramValue2)])
|
|
35765
|
-
);
|
|
35766
35790
|
var queryTools = [
|
|
35767
35791
|
{
|
|
35768
35792
|
name: "pg_query",
|
|
@@ -35777,7 +35801,7 @@ var queryTools = [
|
|
|
35777
35801
|
},
|
|
35778
35802
|
inputSchema: external_exports3.object({
|
|
35779
35803
|
sql: external_exports3.string().min(1).max(1e6).describe("The SQL statement to execute. Hard cap of 1 MB."),
|
|
35780
|
-
params: external_exports3.array(
|
|
35804
|
+
params: external_exports3.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
|
|
35781
35805
|
}),
|
|
35782
35806
|
handler: async (input) => {
|
|
35783
35807
|
const { sql, params } = input;
|
|
@@ -36192,9 +36216,8 @@ var statsTools = [
|
|
|
36192
36216
|
n_live_tup::text AS live_tuples,
|
|
36193
36217
|
seq_tup_read::text AS seq_tup_read,
|
|
36194
36218
|
CASE
|
|
36195
|
-
WHEN COALESCE(idx_scan, 0) = 0
|
|
36196
|
-
|
|
36197
|
-
ELSE (seq_scan::numeric / NULLIF(idx_scan, 0))::numeric(10, 2)::float8
|
|
36219
|
+
WHEN COALESCE(idx_scan, 0) = 0 THEN NULL
|
|
36220
|
+
ELSE (seq_scan::numeric / idx_scan)::numeric(10, 2)::float8
|
|
36198
36221
|
END AS ratio
|
|
36199
36222
|
FROM pg_catalog.pg_stat_user_tables
|
|
36200
36223
|
WHERE n_live_tup >= $1
|
|
@@ -36260,7 +36283,7 @@ function compareVersions(a, b) {
|
|
|
36260
36283
|
}
|
|
36261
36284
|
|
|
36262
36285
|
// src/index.ts
|
|
36263
|
-
var version2 = true ? "0.3.
|
|
36286
|
+
var version2 = true ? "0.3.3" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
36264
36287
|
var subcommand = process.argv[2];
|
|
36265
36288
|
if (subcommand === "version" || subcommand === "--version") {
|
|
36266
36289
|
console.log(version2);
|
|
@@ -36322,4 +36345,7 @@ process.on("SIGINT", () => {
|
|
|
36322
36345
|
process.on("SIGTERM", () => {
|
|
36323
36346
|
void cleanup().finally(() => process.exit(0));
|
|
36324
36347
|
});
|
|
36348
|
+
process.stdin.on("end", () => {
|
|
36349
|
+
void cleanup().finally(() => process.exit(0));
|
|
36350
|
+
});
|
|
36325
36351
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/postgres-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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>",
|
|
@@ -38,12 +38,11 @@
|
|
|
38
38
|
"lint:fix": "biome check --write src/",
|
|
39
39
|
"prepublishOnly": "npm run build"
|
|
40
40
|
},
|
|
41
|
-
"dependencies": {},
|
|
42
41
|
"devDependencies": {
|
|
43
42
|
"@biomejs/biome": "^2.4.12",
|
|
44
43
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
45
44
|
"@types/node": "^25.6.0",
|
|
46
|
-
"@types/pg": "^8.
|
|
45
|
+
"@types/pg": "^8.20.0",
|
|
47
46
|
"esbuild": "^0.28.0",
|
|
48
47
|
"pg": "^8.13.0",
|
|
49
48
|
"typescript": "^6.0.3",
|