@yawlabs/postgres-mcp 0.5.4 → 0.6.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/CHANGELOG.md +59 -0
- package/README.md +55 -4
- package/dist/index.js +75 -38
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Fixed
|
|
11
|
+
- `pg_advisor` `tables_without_primary_key` now includes partitioned-table
|
|
12
|
+
parents (`relkind='p'`) alongside plain heap tables. A partitioned table
|
|
13
|
+
with no PK is a real design-drift signal -- and the neighboring
|
|
14
|
+
`public_tables_without_rls` check already covered both relkinds, so the
|
|
15
|
+
inconsistency was an oversight. Partition children still inherit the
|
|
16
|
+
parent's PK as an `indisprimary` index, so they remain filtered out by
|
|
17
|
+
the existing `NOT EXISTS` clause.
|
|
18
|
+
- `pg_replication_status` now surfaces partial failures via a top-level
|
|
19
|
+
`_warnings` array instead of short-circuiting on the first sub-query
|
|
20
|
+
failure. Matches the convention already used by `pg_health`,
|
|
21
|
+
`pg_describe_table`, and `pg_advisor`. When the WAL position lookup
|
|
22
|
+
fails, `is_replica` is now `null` rather than `false` so a permission
|
|
23
|
+
error can't be mistaken for "this is a primary."
|
|
24
|
+
- `identSchema` (shared schema/table/column-name validator) now enforces
|
|
25
|
+
postgres's 63-byte `NAMEDATALEN` limit on byte length, not JS char
|
|
26
|
+
length. A multi-byte identifier like 32x `é` (64 UTF-8 bytes) used to
|
|
27
|
+
pass validation but postgres would silently truncate it; now it fails
|
|
28
|
+
at the call boundary with a clear message. Centralized in `params.ts`
|
|
29
|
+
so `schemas.ts`, `stats.ts`, `admin.ts`, and `explain.ts` share one
|
|
30
|
+
definition. `pg_explain.hypothetical_indexes` validates the same byte
|
|
31
|
+
limit per-piece on the `schema.table` form and per-column inside
|
|
32
|
+
`validateHypoIndex`, so direct handler calls that bypass Zod still
|
|
33
|
+
get the protection.
|
|
34
|
+
- `getSslConfig` now logs a one-shot stderr warning when
|
|
35
|
+
`POSTGRES_SSL_REJECT_UNAUTHORIZED` is set to an unrecognized value
|
|
36
|
+
(typo, empty string, ...). Previously the typo silently fell through
|
|
37
|
+
to pg's default, indistinguishable from "env var unset" -- a connection
|
|
38
|
+
with unintended TLS posture could land without any signal.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- `pg_top_queries` extension-presence and version probes consolidated
|
|
42
|
+
into a single catalog round-trip (was two). The actual stats query
|
|
43
|
+
remains a second round-trip since the column names are version-dynamic.
|
|
44
|
+
|
|
45
|
+
## [0.6.0] - 2026-05-14
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
- New `pg_readonly` tool. Always runs inside `BEGIN READ ONLY` regardless of
|
|
49
|
+
`ALLOW_WRITES`, so postgres itself rejects any write attempt. The point is
|
|
50
|
+
to give hosts that gate tools individually (Claude Code permissions,
|
|
51
|
+
mcp.hosting per-tool toggles) a stable always-safe target to auto-allow,
|
|
52
|
+
independent of how the server is configured. Same input shape as `pg_query`
|
|
53
|
+
(`sql` + `params`).
|
|
54
|
+
- New "Configuring access" README section walking through the recommended
|
|
55
|
+
least-privileged-role posture: `CREATE ROLE mcp_reader ... GRANT
|
|
56
|
+
pg_read_all_data` for read-only agents, and a `mcp_writer` example with
|
|
57
|
+
table-level grants for scoped writes. The role is the primary access
|
|
58
|
+
control; `ALLOW_WRITES` is positioned as secondary belt-and-braces.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
- `pg_query` description leads with role-based access control. `ALLOW_WRITES`
|
|
62
|
+
is now framed as a secondary gate, with the role in `DATABASE_URL` as the
|
|
63
|
+
authoritative one. No behavior change.
|
|
64
|
+
- README "Why this one?" read-only bullet expanded: `pg_query` continues to
|
|
65
|
+
default to read-only via `BEGIN READ ONLY`, and `pg_readonly` is the new
|
|
66
|
+
unconditional read tool. Configuration table and troubleshooting entry
|
|
67
|
+
for `ALLOW_WRITES` updated to point at the Configuring access section.
|
|
68
|
+
|
|
10
69
|
## [0.5.3] - 2026-05-14
|
|
11
70
|
|
|
12
71
|
### Security
|
package/README.md
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
9
9
|
|
|
10
|
+
[](https://mcp.hosting/install?name=Postgres&command=npx&args=-y%2C%40yawlabs%2Fpostgres-mcp&description=Query%20PostgreSQL%20-%20schema%20introspection%2C%20EXPLAIN%20plans%2C%20health%20diagnostics%2C%20read-only%20by%20default&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fpostgres-mcp)
|
|
11
|
+
|
|
12
|
+
One click adds this to your [mcp.hosting](https://mcp.hosting) account so it syncs to every MCP client you use. Or install manually below.
|
|
13
|
+
|
|
10
14
|
## Backstory
|
|
11
15
|
|
|
12
16
|
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.
|
|
@@ -23,7 +27,8 @@ None of them position themselves as a general-purpose daily driver you'd hand to
|
|
|
23
27
|
|
|
24
28
|
## Why this one?
|
|
25
29
|
|
|
26
|
-
- **Read-only by default** - user SQL
|
|
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.
|
|
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).
|
|
27
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.
|
|
28
33
|
- **Parameterized queries** - `pg_query` takes a `params` array for `$1`, `$2`, etc. No string-interpolated SQL in our code path.
|
|
29
34
|
- **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`.
|
|
@@ -90,6 +95,51 @@ Read-only is the default. If you want the agent to be able to `INSERT`, `UPDATE`
|
|
|
90
95
|
|
|
91
96
|
Prefer scoping this to dev/test databases - for production, leave writes off and use migration tools out-of-band.
|
|
92
97
|
|
|
98
|
+
## Configuring access
|
|
99
|
+
|
|
100
|
+
The role in `DATABASE_URL` is the primary access control. Postgres has had a battle-tested permission system for 30 years; lean on it instead of relying on `ALLOW_WRITES` alone. A least-privileged role makes writes server-rejected no matter what tools or env vars are configured.
|
|
101
|
+
|
|
102
|
+
**Read-only agent (recommended default):**
|
|
103
|
+
|
|
104
|
+
```sql
|
|
105
|
+
CREATE ROLE mcp_reader LOGIN PASSWORD 'change-me';
|
|
106
|
+
GRANT CONNECT ON DATABASE your_db TO mcp_reader;
|
|
107
|
+
GRANT USAGE ON SCHEMA public TO mcp_reader;
|
|
108
|
+
GRANT pg_read_all_data TO mcp_reader;
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Point `DATABASE_URL` at `mcp_reader`. Postgres rejects every write, every DDL, every privilege change - regardless of `ALLOW_WRITES`. No app-level guard to bypass; the database is the boundary.
|
|
112
|
+
|
|
113
|
+
**Scoped write agent (dev/test or narrow production use):**
|
|
114
|
+
|
|
115
|
+
```sql
|
|
116
|
+
CREATE ROLE mcp_writer LOGIN PASSWORD 'change-me';
|
|
117
|
+
GRANT CONNECT ON DATABASE your_db TO mcp_writer;
|
|
118
|
+
GRANT USAGE ON SCHEMA public TO mcp_writer;
|
|
119
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO mcp_writer;
|
|
120
|
+
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO mcp_writer;
|
|
121
|
+
-- DDL not granted -- the agent can change data but not schema.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Set `ALLOW_WRITES=1` so `pg_query` will issue writes, and rely on the role to keep the agent away from DDL and other schemas.
|
|
125
|
+
|
|
126
|
+
**Per-tool gating in the host:**
|
|
127
|
+
|
|
128
|
+
Tools split cleanly across two authority classes:
|
|
129
|
+
|
|
130
|
+
- **Auto-allow:** `pg_readonly` (server-side `BEGIN READ ONLY`, unconditional), plus the introspection tools (`pg_list_*`, `pg_describe_table`, `pg_search_columns`, `pg_explain` without ANALYZE-of-write, `pg_health`, `pg_inspect_locks`, `pg_table_bloat`, `pg_unused_indexes`, `pg_top_queries`, `pg_replication_status`, `pg_advisor`, `pg_table_privileges`, `pg_list_roles`).
|
|
131
|
+
- **Always prompt:** `pg_query` (can write when the role allows it), `pg_kill` (changes session state).
|
|
132
|
+
|
|
133
|
+
Claude Code's `permissions` block and mcp.hosting's per-tool toggle both honor this split.
|
|
134
|
+
|
|
135
|
+
**`ALLOW_WRITES` as defense-in-depth:**
|
|
136
|
+
|
|
137
|
+
`ALLOW_WRITES` is a secondary belt-and-braces gate. Useful when:
|
|
138
|
+
- You're on a managed database where creating a second role is awkward (some Supabase/Neon plans).
|
|
139
|
+
- You want a single role that can write, but want the MCP server to refuse writes anyway during normal operation.
|
|
140
|
+
|
|
141
|
+
Otherwise, configure the role and stop relying on `ALLOW_WRITES`.
|
|
142
|
+
|
|
93
143
|
## What can an agent do with this?
|
|
94
144
|
|
|
95
145
|
Once connected, the agent picks tools automatically based on what you ask. A few single-tool examples:
|
|
@@ -111,7 +161,8 @@ The bigger leverage is multi-tool reasoning. A few real workflows:
|
|
|
111
161
|
|
|
112
162
|
| Tool | Description |
|
|
113
163
|
|------|-------------|
|
|
114
|
-
| `
|
|
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. |
|
|
165
|
+
| `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`. |
|
|
115
166
|
| `pg_list_schemas` | List non-system schemas. |
|
|
116
167
|
| `pg_list_tables` | List tables (and optionally views) in a schema with estimated row counts. Paginated via `limit`/`offset`. |
|
|
117
168
|
| `pg_describe_table` | Kind, columns, PK, outgoing FKs, incoming FKs (`referenced_by`), CHECK / UNIQUE / EXCLUDE constraints, indexes, and partition parent/children for a relation. |
|
|
@@ -139,7 +190,7 @@ All env vars are read from the MCP server's environment:
|
|
|
139
190
|
| Variable | Default | Purpose |
|
|
140
191
|
|----------|---------|---------|
|
|
141
192
|
| `DATABASE_URL` | (required) | PostgreSQL connection string. |
|
|
142
|
-
| `ALLOW_WRITES` | unset | Set to `1` or `true` to
|
|
193
|
+
| `ALLOW_WRITES` | unset | Secondary write gate for `pg_query` and `pg_explain` ANALYZE-of-writes. Set to `1` or `true` to lift the `BEGIN READ ONLY` wrapper. The role in `DATABASE_URL` is the primary control - see [Configuring access](#configuring-access). Does not affect `pg_readonly`, which is unconditional. |
|
|
143
194
|
| `POSTGRES_STATEMENT_TIMEOUT_MS` | `30000` | Per-statement timeout. |
|
|
144
195
|
| `POSTGRES_CONNECTION_TIMEOUT_MS` | `10000` | TCP connect timeout. Without this, a dead host hangs until the OS gives up (~2 minutes). |
|
|
145
196
|
| `POSTGRES_MAX_ROWS` | `1000` | Cap on rows returned by `pg_query`. |
|
|
@@ -179,7 +230,7 @@ This disables certificate chain verification only -- the TCP connection is still
|
|
|
179
230
|
|
|
180
231
|
**`canceling statement due to statement timeout`** - A single query exceeded `POSTGRES_STATEMENT_TIMEOUT_MS` (default 30s). Increase it, narrow the query with `WHERE`, or add an index. This is working as designed -- the timeout exists so a runaway query cannot hang the agent.
|
|
181
232
|
|
|
182
|
-
**`Write blocked: this server is in read-only mode`** - You asked the agent to write but `ALLOW_WRITES` is not set.
|
|
233
|
+
**`Write blocked: this server is in read-only mode`** - You asked the agent to write via `pg_query` but `ALLOW_WRITES` is not set. Either add `ALLOW_WRITES=1` to the `env` block of `.mcp.json` and restart your MCP client (dev/test DBs), or - cleaner for production - use a role with `INSERT/UPDATE/DELETE` grants in `DATABASE_URL` and keep `ALLOW_WRITES` unset. See [Configuring access](#configuring-access). Note that `pg_readonly` always rejects writes; if you want writes, the call has to go through `pg_query`.
|
|
183
234
|
|
|
184
235
|
**Connection pool exhaustion with PgBouncer transaction mode or pglite-socket** - These backends don't support concurrent queries on a single connection. Set `POSTGRES_POOL_MAX=1` in the env block.
|
|
185
236
|
|
package/dist/index.js
CHANGED
|
@@ -36117,6 +36117,9 @@ function getSslConfig() {
|
|
|
36117
36117
|
if (raw === void 0) return void 0;
|
|
36118
36118
|
if (raw === "0" || raw === "false") return { rejectUnauthorized: false };
|
|
36119
36119
|
if (raw === "1" || raw === "true") return { rejectUnauthorized: true };
|
|
36120
|
+
console.error(
|
|
36121
|
+
`[postgres-mcp] POSTGRES_SSL_REJECT_UNAUTHORIZED=${JSON.stringify(raw)} not recognized; expected "0", "false", "1", or "true". Deferring to the pg driver / connection-string default.`
|
|
36122
|
+
);
|
|
36120
36123
|
return void 0;
|
|
36121
36124
|
}
|
|
36122
36125
|
function getPool() {
|
|
@@ -36346,6 +36349,14 @@ async function shutdown() {
|
|
|
36346
36349
|
}
|
|
36347
36350
|
}
|
|
36348
36351
|
|
|
36352
|
+
// src/tools/params.ts
|
|
36353
|
+
var paramValue = external_exports.lazy(
|
|
36354
|
+
() => external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null(), external_exports.array(paramValue), external_exports.record(external_exports.string(), paramValue)])
|
|
36355
|
+
);
|
|
36356
|
+
var identSchema = external_exports.string().min(1).max(63).refine((v) => Buffer.byteLength(v, "utf8") <= 63, {
|
|
36357
|
+
message: "Identifier exceeds PostgreSQL's 63-byte NAMEDATALEN limit (multi-byte characters count as multiple bytes)."
|
|
36358
|
+
});
|
|
36359
|
+
|
|
36349
36360
|
// src/tools/admin.ts
|
|
36350
36361
|
var adminTools = [
|
|
36351
36362
|
{
|
|
@@ -36445,8 +36456,8 @@ var adminTools = [
|
|
|
36445
36456
|
openWorldHint: true
|
|
36446
36457
|
},
|
|
36447
36458
|
inputSchema: external_exports.object({
|
|
36448
|
-
schema:
|
|
36449
|
-
table:
|
|
36459
|
+
schema: identSchema.default("public").describe("Schema name (defaults to 'public')."),
|
|
36460
|
+
table: identSchema.optional().describe("Table name. Omit to list privileges for all tables in the schema.")
|
|
36450
36461
|
}),
|
|
36451
36462
|
handler: async (input) => {
|
|
36452
36463
|
const { schema, table } = input;
|
|
@@ -36548,16 +36559,18 @@ var adminTools = [
|
|
|
36548
36559
|
END AS wal_position`
|
|
36549
36560
|
)
|
|
36550
36561
|
]);
|
|
36551
|
-
|
|
36552
|
-
if (!
|
|
36553
|
-
if (!
|
|
36562
|
+
const warnings = [];
|
|
36563
|
+
if (!slotsRes.ok) warnings.push(`slots fetch failed: ${slotsRes.error}`);
|
|
36564
|
+
if (!replicasRes.ok) warnings.push(`replicas fetch failed: ${replicasRes.error}`);
|
|
36565
|
+
if (!walRes.ok) warnings.push(`wal_position fetch failed: ${walRes.error}`);
|
|
36554
36566
|
return {
|
|
36555
36567
|
ok: true,
|
|
36556
36568
|
data: {
|
|
36557
|
-
is_replica: walRes.data?.[0]?.is_in_recovery ?? false,
|
|
36558
|
-
wal_position: walRes.data?.[0]?.wal_position ?? null,
|
|
36559
|
-
slots: slotsRes.data ?? [],
|
|
36560
|
-
replicas: replicasRes.data ?? []
|
|
36569
|
+
is_replica: walRes.ok ? walRes.data?.[0]?.is_in_recovery ?? false : null,
|
|
36570
|
+
wal_position: walRes.ok ? walRes.data?.[0]?.wal_position ?? null : null,
|
|
36571
|
+
slots: slotsRes.ok ? slotsRes.data ?? [] : [],
|
|
36572
|
+
replicas: replicasRes.ok ? replicasRes.data ?? [] : [],
|
|
36573
|
+
...warnings.length > 0 ? { _warnings: warnings } : {}
|
|
36561
36574
|
}
|
|
36562
36575
|
};
|
|
36563
36576
|
});
|
|
@@ -36575,7 +36588,7 @@ var adminTools = [
|
|
|
36575
36588
|
},
|
|
36576
36589
|
inputSchema: external_exports.object({
|
|
36577
36590
|
seqExhaustionThreshold: external_exports.number().min(0).max(1).default(0.5).describe("Minimum used-fraction (last_value / max_value) to flag a sequence (default 0.5 = 50%)."),
|
|
36578
|
-
rlsSchemas: external_exports.array(
|
|
36591
|
+
rlsSchemas: external_exports.array(identSchema).default(["public"]).describe("Schemas where RLS-missing should be flagged. Defaults to ['public']."),
|
|
36579
36592
|
limit: external_exports.number().int().min(1).max(500).default(50).describe("Max rows per category (default 50).")
|
|
36580
36593
|
}),
|
|
36581
36594
|
handler: async (input) => {
|
|
@@ -36603,15 +36616,22 @@ var adminTools = [
|
|
|
36603
36616
|
[seqExhaustionThreshold, limit]
|
|
36604
36617
|
),
|
|
36605
36618
|
run(
|
|
36606
|
-
//
|
|
36607
|
-
//
|
|
36608
|
-
//
|
|
36619
|
+
// Includes partitioned parents (relkind='p') alongside plain heap
|
|
36620
|
+
// tables ('r'). A partitioned table with no PK is a real design-
|
|
36621
|
+
// drift signal -- if a PK exists on a partitioned table it must
|
|
36622
|
+
// include the partition key columns, but having no PK at all is
|
|
36623
|
+
// legal and usually unintended. Matches the relkind filter used
|
|
36624
|
+
// by public_tables_without_rls below.
|
|
36625
|
+
//
|
|
36626
|
+
// Partition children (relkind='r') inherit the parent's PK as an
|
|
36627
|
+
// indisprimary index on the child, so the NOT EXISTS clause keeps
|
|
36628
|
+
// already filtering them out.
|
|
36609
36629
|
`SELECT
|
|
36610
36630
|
n.nspname AS schema,
|
|
36611
36631
|
c.relname AS "table"
|
|
36612
36632
|
FROM pg_catalog.pg_class c
|
|
36613
36633
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
36614
|
-
WHERE c.relkind
|
|
36634
|
+
WHERE c.relkind IN ('r', 'p')
|
|
36615
36635
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
36616
36636
|
AND n.nspname NOT LIKE 'pg_%'
|
|
36617
36637
|
AND NOT EXISTS (
|
|
@@ -36663,7 +36683,7 @@ var adminTools = [
|
|
|
36663
36683
|
openWorldHint: true
|
|
36664
36684
|
},
|
|
36665
36685
|
inputSchema: external_exports.object({
|
|
36666
|
-
schema:
|
|
36686
|
+
schema: identSchema.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
36667
36687
|
minDeadRatio: external_exports.number().min(0).max(1).default(0.1).describe("Minimum dead-tuple fraction to include - dead / (live + dead). Default 0.1 = 10%."),
|
|
36668
36688
|
limit: external_exports.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
|
|
36669
36689
|
}),
|
|
@@ -36699,16 +36719,15 @@ var adminTools = [
|
|
|
36699
36719
|
}
|
|
36700
36720
|
];
|
|
36701
36721
|
|
|
36702
|
-
// src/tools/params.ts
|
|
36703
|
-
var paramValue = external_exports.lazy(
|
|
36704
|
-
() => external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null(), external_exports.array(paramValue), external_exports.record(external_exports.string(), paramValue)])
|
|
36705
|
-
);
|
|
36706
|
-
|
|
36707
36722
|
// src/tools/explain.ts
|
|
36708
36723
|
var indexAccessMethod = external_exports.enum(["btree", "hash", "gin", "gist", "brin", "spgist"]);
|
|
36709
36724
|
var hypotheticalIndex = external_exports.object({
|
|
36725
|
+
// `table` is `schema.table` or `table`. The 127-char ceiling is a generous
|
|
36726
|
+
// upper bound on the combined form -- the actual NAMEDATALEN (63-byte)
|
|
36727
|
+
// limit on each piece after the split is enforced in validateHypoIndex,
|
|
36728
|
+
// since splitting and per-piece byte-checking is awkward in Zod.
|
|
36710
36729
|
table: external_exports.string().min(1).max(127).describe("Target table. Use `schema.table` (e.g. `public.users`) or just `table` for the search_path."),
|
|
36711
|
-
columns: external_exports.array(
|
|
36730
|
+
columns: external_exports.array(identSchema).min(1).describe("Column names in index order. Quoted identifiers are not supported here -- pass plain names."),
|
|
36712
36731
|
using: indexAccessMethod.default("btree").describe("Index access method. btree is the right answer for almost every query.")
|
|
36713
36732
|
});
|
|
36714
36733
|
function quoteIdent(name) {
|
|
@@ -36722,11 +36741,17 @@ function validateHypoIndex(idx) {
|
|
|
36722
36741
|
if (piece.includes('"')) {
|
|
36723
36742
|
return `Hypothetical index table ${JSON.stringify(idx.table)} contains a double-quote; pass plain identifier names without pre-quoting.`;
|
|
36724
36743
|
}
|
|
36744
|
+
if (Buffer.byteLength(piece, "utf8") > 63) {
|
|
36745
|
+
return `Hypothetical index table piece ${JSON.stringify(piece)} exceeds PostgreSQL's 63-byte NAMEDATALEN limit (multi-byte characters count as multiple bytes).`;
|
|
36746
|
+
}
|
|
36725
36747
|
}
|
|
36726
36748
|
for (const col of idx.columns) {
|
|
36727
36749
|
if (col.includes('"')) {
|
|
36728
36750
|
return `Hypothetical index column ${JSON.stringify(col)} contains a double-quote; pass plain identifier names without pre-quoting.`;
|
|
36729
36751
|
}
|
|
36752
|
+
if (Buffer.byteLength(col, "utf8") > 63) {
|
|
36753
|
+
return `Hypothetical index column ${JSON.stringify(col)} exceeds PostgreSQL's 63-byte NAMEDATALEN limit (multi-byte characters count as multiple bytes).`;
|
|
36754
|
+
}
|
|
36730
36755
|
}
|
|
36731
36756
|
return null;
|
|
36732
36757
|
}
|
|
@@ -36904,13 +36929,32 @@ var healthTools = [
|
|
|
36904
36929
|
|
|
36905
36930
|
// src/tools/query.ts
|
|
36906
36931
|
var queryTools = [
|
|
36932
|
+
{
|
|
36933
|
+
name: "pg_readonly",
|
|
36934
|
+
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.",
|
|
36935
|
+
annotations: {
|
|
36936
|
+
title: "Run read-only SQL",
|
|
36937
|
+
readOnlyHint: true,
|
|
36938
|
+
destructiveHint: false,
|
|
36939
|
+
idempotentHint: true,
|
|
36940
|
+
openWorldHint: true
|
|
36941
|
+
},
|
|
36942
|
+
inputSchema: external_exports.object({
|
|
36943
|
+
sql: external_exports.string().min(1).max(1e6).describe("The SQL statement to execute. Hard cap of 1 MB."),
|
|
36944
|
+
params: external_exports.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
|
|
36945
|
+
}),
|
|
36946
|
+
handler: async (input) => {
|
|
36947
|
+
const { sql, params } = input;
|
|
36948
|
+
return runReadOnly(sql, params ?? []);
|
|
36949
|
+
}
|
|
36950
|
+
},
|
|
36907
36951
|
{
|
|
36908
36952
|
name: "pg_query",
|
|
36909
|
-
description: "Run a SQL query against the configured PostgreSQL database.
|
|
36953
|
+
description: "Run a SQL query against the configured PostgreSQL database. Writes are gated by the role in `DATABASE_URL` first and `ALLOW_WRITES` second: a role created with `GRANT pg_read_all_data` makes writes server-rejected regardless of `ALLOW_WRITES`, and is the recommended way to scope agent access. `ALLOW_WRITES=1` is a secondary belt-and-braces gate - useful for managed databases where creating a second role is awkward. For read-only access where you want the guarantee in the tool name, prefer `pg_readonly`. 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). Dates and UUIDs can be passed as ISO strings. Large result sets are truncated to POSTGRES_MAX_ROWS (default 1000) with a `truncated: true` flag.",
|
|
36910
36954
|
annotations: {
|
|
36911
36955
|
title: "Run SQL query",
|
|
36912
36956
|
readOnlyHint: false,
|
|
36913
|
-
// conditionally destructive based on ALLOW_WRITES
|
|
36957
|
+
// conditionally destructive based on role + ALLOW_WRITES
|
|
36914
36958
|
destructiveHint: true,
|
|
36915
36959
|
idempotentHint: false,
|
|
36916
36960
|
openWorldHint: true
|
|
@@ -36930,7 +36974,6 @@ var queryTools = [
|
|
|
36930
36974
|
];
|
|
36931
36975
|
|
|
36932
36976
|
// src/tools/schemas.ts
|
|
36933
|
-
var identSchema = external_exports.string().min(1).max(63);
|
|
36934
36977
|
var schemaTools = [
|
|
36935
36978
|
{
|
|
36936
36979
|
name: "pg_list_schemas",
|
|
@@ -37346,7 +37389,6 @@ var schemaTools = [
|
|
|
37346
37389
|
];
|
|
37347
37390
|
|
|
37348
37391
|
// src/tools/stats.ts
|
|
37349
|
-
var identSchema2 = external_exports.string().min(1).max(63);
|
|
37350
37392
|
var statsTools = [
|
|
37351
37393
|
{
|
|
37352
37394
|
name: "pg_top_queries",
|
|
@@ -37364,22 +37406,17 @@ var statsTools = [
|
|
|
37364
37406
|
}),
|
|
37365
37407
|
handler: async (input) => {
|
|
37366
37408
|
const { orderBy, limit } = input;
|
|
37367
|
-
const
|
|
37368
|
-
`SELECT
|
|
37369
|
-
SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'
|
|
37370
|
-
) AS installed`
|
|
37409
|
+
const versionRes = await runInternal(
|
|
37410
|
+
`SELECT extversion AS version FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'`
|
|
37371
37411
|
);
|
|
37372
|
-
if (!
|
|
37373
|
-
if (!
|
|
37412
|
+
if (!versionRes.ok) return versionRes;
|
|
37413
|
+
if (!versionRes.data || versionRes.data.length === 0) {
|
|
37374
37414
|
return {
|
|
37375
37415
|
ok: false,
|
|
37376
37416
|
error: "pg_stat_statements extension is not installed. Install it with `CREATE EXTENSION pg_stat_statements;` (may require superuser) and add `pg_stat_statements` to `shared_preload_libraries` in postgresql.conf, then restart."
|
|
37377
37417
|
};
|
|
37378
37418
|
}
|
|
37379
|
-
const
|
|
37380
|
-
`SELECT extversion AS version FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'`
|
|
37381
|
-
);
|
|
37382
|
-
const extVersion = versionRes.ok ? versionRes.data?.[0]?.version ?? "0" : "0";
|
|
37419
|
+
const extVersion = versionRes.data[0]?.version ?? "0";
|
|
37383
37420
|
const useExecSuffix = compareVersions(extVersion, "1.8") >= 0;
|
|
37384
37421
|
const totalCol = useExecSuffix ? "total_exec_time" : "total_time";
|
|
37385
37422
|
const meanCol = useExecSuffix ? "mean_exec_time" : "mean_time";
|
|
@@ -37422,7 +37459,7 @@ var statsTools = [
|
|
|
37422
37459
|
openWorldHint: true
|
|
37423
37460
|
},
|
|
37424
37461
|
inputSchema: external_exports.object({
|
|
37425
|
-
schema:
|
|
37462
|
+
schema: identSchema.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
37426
37463
|
minSize: external_exports.number().int().min(0).default(1e3).describe("Minimum live tuple count to include (default 1000, filters out tiny/empty tables)."),
|
|
37427
37464
|
limit: external_exports.number().int().min(1).max(100).default(20).describe("Max rows to return (default 20).")
|
|
37428
37465
|
}),
|
|
@@ -37463,7 +37500,7 @@ var statsTools = [
|
|
|
37463
37500
|
openWorldHint: true
|
|
37464
37501
|
},
|
|
37465
37502
|
inputSchema: external_exports.object({
|
|
37466
|
-
schema:
|
|
37503
|
+
schema: identSchema.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
37467
37504
|
maxScans: external_exports.number().int().min(0).default(10).describe("Include indexes with scan count <= this (default 10). Use 0 for 'never scanned'."),
|
|
37468
37505
|
limit: external_exports.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
|
|
37469
37506
|
}),
|
|
@@ -37510,7 +37547,7 @@ function compareVersions(a, b) {
|
|
|
37510
37547
|
}
|
|
37511
37548
|
|
|
37512
37549
|
// src/index.ts
|
|
37513
|
-
var version2 = true ? "0.
|
|
37550
|
+
var version2 = true ? "0.6.1" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
37514
37551
|
var subcommand = process.argv[2];
|
|
37515
37552
|
if (subcommand === "version" || subcommand === "--version") {
|
|
37516
37553
|
console.log(version2);
|
package/package.json
CHANGED