@yawlabs/postgres-mcp 0.6.20 → 0.8.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 +150 -0
- package/README.md +10 -2
- package/dist/index.js +172 -53
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
> **Version note:** the "Changed (breaking)" entries below alter the shape of
|
|
11
|
+
> tool output and the CLI's exit behavior. Under SemVer-for-0.x that makes the
|
|
12
|
+
> next release a MINOR bump -- `0.8.0`, not `0.7.1`. `release.sh` performs the
|
|
13
|
+
> actual bump (`npm version`) and syncs `server.json`, so nothing is pre-bumped
|
|
14
|
+
> here; pass `0.8.0` when cutting the release.
|
|
15
|
+
|
|
16
|
+
### Changed (breaking)
|
|
17
|
+
|
|
18
|
+
- `QueryResult.command` is now OPTIONAL, and is omitted entirely for statements
|
|
19
|
+
that run on the cursor path (every SELECT and other row-returning statement).
|
|
20
|
+
Previously it reported `"FETCH"` for all of them -- the command tag of the
|
|
21
|
+
internal `FETCH`, not of the user's statement. Postgres does not surface the
|
|
22
|
+
inner tag through a cursor and there is no source of truth to substitute, so
|
|
23
|
+
the field is absent rather than wrong. It is still present and correct on the
|
|
24
|
+
direct-exec path (DDL and DML without `RETURNING`), where node-pg reports the
|
|
25
|
+
first word of the real tag: `CREATE`, `INSERT`, etc.
|
|
26
|
+
- `rowCount` no longer exceeds `rows.length` on a truncated cursor-path result.
|
|
27
|
+
The bounded fetch deliberately reads `POSTGRES_MAX_ROWS + 1` rows to detect
|
|
28
|
+
truncation, and that extra probe row was leaking into `rowCount` -- callers
|
|
29
|
+
saw `rowCount: 1001` next to 1000 rows. The direct-exec path is unchanged and
|
|
30
|
+
still reports the AFFECTED-row count, which is independent of how many rows
|
|
31
|
+
come back: a truncated `INSERT ... RETURNING` of 10 rows correctly reports
|
|
32
|
+
`rowCount: 10`, `rows.length: 3`, `truncated: true`.
|
|
33
|
+
- `pg_top_queries` is now scoped to the database in `DATABASE_URL`.
|
|
34
|
+
`pg_stat_statements` is cluster-wide, so on a shared cluster the tool
|
|
35
|
+
previously returned normalized query text from unrelated databases. Results
|
|
36
|
+
are filtered by `dbid`, matching every other tool in this server. Callers who
|
|
37
|
+
relied on the cluster-wide view will see fewer rows.
|
|
38
|
+
- The CLI now exits 1 with a usage message on an unrecognized bare argument
|
|
39
|
+
instead of silently starting the stdio server. `postgres-mcp doctor` used to
|
|
40
|
+
print nothing and appear to hang while the server waited for MCP framing on
|
|
41
|
+
stdin. Arguments beginning with `-` are still passed through untouched so
|
|
42
|
+
host-supplied flags keep working, and a positionally-passed connection string
|
|
43
|
+
gets a targeted message pointing at the `DATABASE_URL` env block.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- `pg_describe_table` no longer reports `INCLUDE` (covering) columns as part of
|
|
48
|
+
`primary_key`. PostgreSQL 11+ allows `PRIMARY KEY (id) INCLUDE (label)`, and
|
|
49
|
+
the covering column sits in `pg_index.indkey` next to the key column; the
|
|
50
|
+
query matched the whole vector. An agent reading that would build an invalid
|
|
51
|
+
`ON CONFLICT (id, label)` target. The key columns are now bounded by
|
|
52
|
+
`indnkeyatts`.
|
|
53
|
+
- `resolveTypeNames` no longer throws when `shutdown()` lands mid-flight. The
|
|
54
|
+
module-scoped type cache is nulled by `shutdown()`, and dereferencing it after
|
|
55
|
+
an await (SIGTERM during a tool call, or a test calling `shutdown()` between
|
|
56
|
+
calls) raised on null -- silently costing the response its `dataTypeName`
|
|
57
|
+
fields. The cache is now bound to a local before the first await.
|
|
58
|
+
- Zod schema defaults are re-applied consistently by every tool handler for
|
|
59
|
+
direct (non-MCP) callers, which bypass schema parsing. Previously only
|
|
60
|
+
`pg_explain` and `pg_table_bloat` did this; `pg_advisor` in particular bound
|
|
61
|
+
`undefined` into `n.nspname = ANY($1)` and errored at bind time. `pg_kill`
|
|
62
|
+
defaults to the safer `cancel` mode, so an omitted `mode` can never escalate
|
|
63
|
+
to `terminate`.
|
|
64
|
+
|
|
65
|
+
### Documentation
|
|
66
|
+
|
|
67
|
+
- `pg_readonly`'s description and the README now state the actual scope of
|
|
68
|
+
`BEGIN READ ONLY`: it bounds writes to the DATABASE, not every side effect.
|
|
69
|
+
Functions whose effect lands outside the table data -- `pg_terminate_backend`,
|
|
70
|
+
`pg_cancel_backend`, `pg_read_file`, `lo_export`, `COPY ... TO PROGRAM` -- are
|
|
71
|
+
not blocked by it and are not behind the `ALLOW_WRITES` gate that `pg_kill`
|
|
72
|
+
sits behind. All of them still require privileges the `DATABASE_URL` role must
|
|
73
|
+
hold, so the ROLE is what actually bounds this tool. Auto-allow `pg_readonly`
|
|
74
|
+
with a least-privileged role.
|
|
75
|
+
- `release.sh` no longer suggests `npm login --auth-type=web` on an E401/E404.
|
|
76
|
+
That command overwrites the automation token in `~/.npmrc` with a
|
|
77
|
+
WebAuthn-bound session, and the next publish then fails on a challenge no
|
|
78
|
+
script can answer. It now points at restoring the automation token.
|
|
79
|
+
- Removed stale references to a CI pipeline this repo does not have: there is no
|
|
80
|
+
`.github/`, the binary build and the lint gate are both local.
|
|
81
|
+
|
|
82
|
+
### Testing
|
|
83
|
+
|
|
84
|
+
- `src/index.test.ts`: the CLI entrypoint had no automated coverage at all (it
|
|
85
|
+
cannot be imported -- it calls `server.connect` at the top level), including
|
|
86
|
+
the argv handling that runs before server startup. Now driven as a child
|
|
87
|
+
process: both version flags, the argv guard's three branches, and a full MCP
|
|
88
|
+
`initialize` + `tools/list` handshake that covers the tool-registration wiring.
|
|
89
|
+
- Coverage for the `DECLARE`-succeeded / `FETCH`-failed branch of
|
|
90
|
+
`runUserQueryBounded`, which prevents re-executing a statement whose side
|
|
91
|
+
effects already landed. Engineered with a short `statement_timeout`; a
|
|
92
|
+
sequence acts as the double-execution detector, since `nextval` is
|
|
93
|
+
non-transactional and survives the rollback.
|
|
94
|
+
- Coverage for `dbid` scoping (with a positive control proving the assertion is
|
|
95
|
+
not vacuous), the truncated `INSERT ... RETURNING` row count, `command`
|
|
96
|
+
presence on both paths, `PRIMARY KEY ... INCLUDE`, composite-PK ordering,
|
|
97
|
+
`shutdown()` mid-bootstrap, and `getPool()` without `DATABASE_URL` including
|
|
98
|
+
the win32-only hint branch.
|
|
99
|
+
- `scripts/wsl-pg-setup.sh` now provisions PostgreSQL 15 alongside 17 and 18,
|
|
100
|
+
adds `pg_stat_statements` to `shared_preload_libraries`, and creates
|
|
101
|
+
`pg_stat_statements` + `pgstattuple`. Without those extensions present, every
|
|
102
|
+
`pg_top_queries` test and both `pg_table_bloat` `approx`/`exact` tests took
|
|
103
|
+
their "extension not installed" early return and proved nothing about the
|
|
104
|
+
tool's SQL. PG15 is there for column coverage, not recency:
|
|
105
|
+
`pg_stat_statements` renamed `blk_read_time` to `shared_blk_read_time` in 1.11
|
|
106
|
+
(PG17), and with only 17/18 in the matrix the pre-1.11 branch was never
|
|
107
|
+
selected.
|
|
108
|
+
|
|
10
109
|
## [0.6.20] - 2026-06-04
|
|
11
110
|
|
|
12
111
|
### Fixed
|
|
@@ -88,6 +187,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
88
187
|
a local-only pre-tag gate; this is just an opt-in fail-fast.
|
|
89
188
|
- `package.json` `package-lock.json` and `server.json` all bumped to 0.6.20.
|
|
90
189
|
|
|
190
|
+
## [0.6.19] - 2026-06-02
|
|
191
|
+
|
|
192
|
+
Release-flow hardening; no library behavior changes shipped in this version.
|
|
193
|
+
|
|
194
|
+
### Fixed
|
|
195
|
+
- `release.sh` refuses to push when origin already has `v<version>` pointing
|
|
196
|
+
at a different commit (rewound tag elsewhere, parallel release race) --
|
|
197
|
+
previously `git push --follow-tags` silently skipped the stale tag and the
|
|
198
|
+
GitHub release linked the wrong commit. Compares tag-object SHAs so resume
|
|
199
|
+
runs don't false-abort.
|
|
200
|
+
- README "Add to Yaw MCP" badge points at the https forwarder so it renders
|
|
201
|
+
as a link on github.com (raw `yaw://` hrefs are stripped).
|
|
202
|
+
|
|
203
|
+
### Added
|
|
204
|
+
- `SKIP_LINT=1` escape hatch in `release.sh` for hosts where the npm
|
|
205
|
+
run-script wrapper segfaults on exit-cleanup (MINGW64-ARM64).
|
|
206
|
+
- `wrapToolHandler` extracted from `index.ts` for testability, with unit
|
|
207
|
+
coverage of the MCP result wrapper and the connect-failure path (expanded
|
|
208
|
+
further in 0.6.20).
|
|
209
|
+
|
|
210
|
+
## [0.6.18] - 2026-05-28
|
|
211
|
+
|
|
212
|
+
### Changed
|
|
213
|
+
- Release publishing consolidated into `release.sh`: the MCP Registry publish
|
|
214
|
+
moved into the script and `release.yml` (plus the non-release CI workflows)
|
|
215
|
+
was dropped. The script hands off to CI when a CI publish path exists and
|
|
216
|
+
publishes from the workstation otherwise.
|
|
217
|
+
|
|
218
|
+
### Fixed
|
|
219
|
+
- `release.sh` syncs `server.json` unconditionally, not only inside the bump
|
|
220
|
+
branch, so a resume run no longer asks mcp-publisher to re-publish the
|
|
221
|
+
previous version (400 duplicate-version).
|
|
222
|
+
- `release.sh` falls back to the gh CLI session token when
|
|
223
|
+
`MCP_REGISTRY_TOKEN` is unset.
|
|
224
|
+
- The release confirmation prompt is tty-gated so non-interactive runs don't
|
|
225
|
+
hang on `read`.
|
|
226
|
+
|
|
227
|
+
### Docs
|
|
228
|
+
- README install badge swapped to the "Add to Yaw MCP" deep link; `npx`
|
|
229
|
+
spawn examples pinned to `@latest` for auto-update.
|
|
230
|
+
|
|
231
|
+
## [0.6.17] - 2026-05-19
|
|
232
|
+
|
|
233
|
+
### Added
|
|
234
|
+
- `release.sh` accepts an optional pre-release commit message as a second
|
|
235
|
+
argument: runs the pre-commit checklist, commits tracked changes, then
|
|
236
|
+
proceeds with the release.
|
|
237
|
+
- Post-publish smoke script (`scripts/post-publish-smoke.sh`) wired into the
|
|
238
|
+
release flow -- exercises the published tarball via a real `npx` install
|
|
239
|
+
instead of trusting `npm view` registry metadata.
|
|
240
|
+
|
|
91
241
|
## [0.6.16] - 2026-05-18
|
|
92
242
|
|
|
93
243
|
### Tests
|
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ None of them position themselves as a general-purpose daily driver you'd hand to
|
|
|
27
27
|
|
|
28
28
|
## Why this one?
|
|
29
29
|
|
|
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
|
|
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 auto-allow it -- paired with a least-privileged role, since `READ ONLY` bounds writes to the database rather than every side effect ([details](#per-tool-gating-in-the-host)).
|
|
31
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).
|
|
32
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.
|
|
33
33
|
- **Parameterized queries** - `pg_query` takes a `params` array for `$1`, `$2`, etc. No string-interpolated SQL in our code path.
|
|
@@ -132,6 +132,8 @@ Tools split cleanly across two authority classes:
|
|
|
132
132
|
|
|
133
133
|
Claude Code's `permissions` block and mcp.hosting's per-tool toggle both honor this split.
|
|
134
134
|
|
|
135
|
+
> **What `READ ONLY` does and does not cover.** A `BEGIN READ ONLY` transaction blocks writes to the *database* -- INSERT/UPDATE/DELETE, DDL, `nextval`/`setval`. It does not block functions whose effect lands outside the table data. `SELECT pg_terminate_backend(...)`, `pg_cancel_backend`, `pg_read_file`, `lo_export`, and `COPY ... TO PROGRAM` all run to completion inside `pg_readonly`, which means auto-allowing `pg_readonly` reaches the same capability that `pg_kill` puts behind `ALLOW_WRITES=1`. Every one of them still requires a privilege the `DATABASE_URL` role must actually hold (`pg_signal_backend`, `pg_read_server_files`, superuser), so **the role is the control that bounds this tool, not the transaction mode.** If you auto-allow `pg_readonly`, use a least-privileged role -- see [Configuring access](#configuring-access).
|
|
136
|
+
|
|
135
137
|
**`ALLOW_WRITES` as defense-in-depth:**
|
|
136
138
|
|
|
137
139
|
`ALLOW_WRITES` is a secondary belt-and-braces gate. Useful when:
|
|
@@ -161,7 +163,7 @@ The bigger leverage is multi-tool reasoning. A few real workflows:
|
|
|
161
163
|
|
|
162
164
|
| Tool | Description |
|
|
163
165
|
|------|-------------|
|
|
164
|
-
| `pg_readonly` | Run a SQL statement
|
|
166
|
+
| `pg_readonly` | Run a SQL statement with no persistent data changes - always inside `BEGIN READ ONLY`, regardless of `ALLOW_WRITES`. The recommended tool for read access, and the one to auto-allow; pair it with a least-privileged role ([why](#per-tool-gating-in-the-host)). |
|
|
165
167
|
| `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`. |
|
|
166
168
|
| `pg_list_schemas` | List non-system schemas. |
|
|
167
169
|
| `pg_list_tables` | List tables (and optionally views) in a schema with estimated row counts. Paginated via `limit`/`offset`. |
|
|
@@ -246,6 +248,12 @@ DATABASE_URL='postgres://user:pass@host:5432/db' POSTGRES_MCP_INTEGRATION=1 npm
|
|
|
246
248
|
|
|
247
249
|
The integration suite assumes a disposable database -- it creates and drops a `test_fixture` schema. Don't point it at anything you care about.
|
|
248
250
|
|
|
251
|
+
To also run the destructive tests (REVOKE / restricted-role path), add `POSTGRES_MCP_DESTRUCTIVE_TESTS=1`. Only safe on a disposable cluster:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
DATABASE_URL='postgres://user:pass@host:5432/db' POSTGRES_MCP_INTEGRATION=1 POSTGRES_MCP_DESTRUCTIVE_TESTS=1 npm run test:integration
|
|
255
|
+
```
|
|
256
|
+
|
|
249
257
|
### Windows: integration tests via WSL2
|
|
250
258
|
|
|
251
259
|
Native Postgres on Windows ARM64 is fragile (UCRT runtime gaps, missing ARM64 builds). The reliable path is a disposable Ubuntu under WSL2 with the integration suite running inside WSL (WSL2's NAT blocks the Windows host from reaching :5432, so don't try to run the tests from PowerShell):
|
package/dist/index.js
CHANGED
|
@@ -11996,6 +11996,9 @@ var require_lib2 = __commonJS({
|
|
|
11996
11996
|
}
|
|
11997
11997
|
});
|
|
11998
11998
|
|
|
11999
|
+
// src/index.ts
|
|
12000
|
+
import { writeSync } from "node:fs";
|
|
12001
|
+
|
|
11999
12002
|
// node_modules/zod/v3/helpers/util.js
|
|
12000
12003
|
var util;
|
|
12001
12004
|
(function(util2) {
|
|
@@ -36144,22 +36147,24 @@ function getPool() {
|
|
|
36144
36147
|
var typeNameCache = null;
|
|
36145
36148
|
async function resolveTypeNames(client, oids) {
|
|
36146
36149
|
if (oids.length === 0) return {};
|
|
36147
|
-
|
|
36148
|
-
|
|
36150
|
+
let cache = typeNameCache;
|
|
36151
|
+
if (!cache) {
|
|
36152
|
+
cache = /* @__PURE__ */ new Map();
|
|
36153
|
+
typeNameCache = cache;
|
|
36149
36154
|
const res = await client.query("SELECT oid, typname FROM pg_catalog.pg_type");
|
|
36150
|
-
for (const row of res.rows)
|
|
36155
|
+
for (const row of res.rows) cache.set(row.oid, row.typname);
|
|
36151
36156
|
}
|
|
36152
|
-
const missing = oids.filter((o) => !
|
|
36157
|
+
const missing = oids.filter((o) => !cache.has(o));
|
|
36153
36158
|
if (missing.length > 0) {
|
|
36154
36159
|
const res = await client.query(
|
|
36155
36160
|
"SELECT oid, typname FROM pg_catalog.pg_type WHERE oid = ANY($1)",
|
|
36156
36161
|
[missing]
|
|
36157
36162
|
);
|
|
36158
|
-
for (const row of res.rows)
|
|
36163
|
+
for (const row of res.rows) cache.set(row.oid, row.typname);
|
|
36159
36164
|
}
|
|
36160
36165
|
const out = {};
|
|
36161
36166
|
for (const oid of oids) {
|
|
36162
|
-
const n =
|
|
36167
|
+
const n = cache.get(oid);
|
|
36163
36168
|
if (n !== void 0) out[oid] = n;
|
|
36164
36169
|
}
|
|
36165
36170
|
return out;
|
|
@@ -36192,18 +36197,19 @@ async function runUserQueryBounded(client, sql, params, maxRows) {
|
|
|
36192
36197
|
} catch {
|
|
36193
36198
|
}
|
|
36194
36199
|
await client.query("RELEASE SAVEPOINT __pgmcp_sp");
|
|
36195
|
-
return fetched;
|
|
36200
|
+
return { result: fetched, viaCursor: true };
|
|
36196
36201
|
} catch (err) {
|
|
36197
36202
|
if (declareSucceeded) {
|
|
36198
36203
|
throw err;
|
|
36199
36204
|
}
|
|
36200
36205
|
await client.query("ROLLBACK TO SAVEPOINT __pgmcp_sp");
|
|
36201
36206
|
await client.query("RELEASE SAVEPOINT __pgmcp_sp");
|
|
36202
|
-
|
|
36207
|
+
const direct = await client.query({
|
|
36203
36208
|
text: sql,
|
|
36204
36209
|
values: params,
|
|
36205
36210
|
queryMode: "extended"
|
|
36206
36211
|
});
|
|
36212
|
+
return { result: direct, viaCursor: false };
|
|
36207
36213
|
}
|
|
36208
36214
|
}
|
|
36209
36215
|
async function safeResolveTypeNames(client, fields) {
|
|
@@ -36214,17 +36220,29 @@ async function safeResolveTypeNames(client, fields) {
|
|
|
36214
36220
|
return {};
|
|
36215
36221
|
}
|
|
36216
36222
|
}
|
|
36217
|
-
function toQueryResult(result, maxRows, typeNames
|
|
36223
|
+
function toQueryResult(result, maxRows, typeNames, viaCursor) {
|
|
36218
36224
|
const truncated = result.rows.length > maxRows;
|
|
36219
36225
|
const rows = truncated ? result.rows.slice(0, maxRows) : result.rows;
|
|
36220
36226
|
return {
|
|
36221
36227
|
rows,
|
|
36222
|
-
|
|
36228
|
+
// Cursor path only: we deliberately FETCH maxRows + 1 to detect
|
|
36229
|
+
// truncation, so `result.rowCount` is one MORE than what we return --
|
|
36230
|
+
// consumers saw rowCount=1001 next to 1000 rows. Report what's in `rows`.
|
|
36231
|
+
//
|
|
36232
|
+
// The direct-exec path must NOT be rewritten. There `rowCount` is the
|
|
36233
|
+
// affected-row count, which is independent of how many rows came back:
|
|
36234
|
+
// `INSERT ... RETURNING` is non-cursorable (DECLARE rejects it with
|
|
36235
|
+
// 42601), so a 10-row insert truncated to 3 must still report 10 rows
|
|
36236
|
+
// affected. Collapsing it to rows.length told the caller 3 rows were
|
|
36237
|
+
// written when 10 were committed.
|
|
36238
|
+
rowCount: truncated && viaCursor ? rows.length : result.rowCount,
|
|
36223
36239
|
fields: result.fields.map((f) => {
|
|
36224
36240
|
const name = typeNames[f.dataTypeID];
|
|
36225
36241
|
return name !== void 0 ? { name: f.name, dataTypeID: f.dataTypeID, dataTypeName: name } : { name: f.name, dataTypeID: f.dataTypeID };
|
|
36226
36242
|
}),
|
|
36227
|
-
|
|
36243
|
+
// See QueryResult.command -- omitted on the cursor path because the tag
|
|
36244
|
+
// there describes the FETCH, not the user's statement.
|
|
36245
|
+
...viaCursor ? {} : { command: result.command },
|
|
36228
36246
|
...truncated ? { truncated: true } : {}
|
|
36229
36247
|
};
|
|
36230
36248
|
}
|
|
@@ -36234,10 +36252,10 @@ async function runReadOnly(sql, params = [], hooks = {}) {
|
|
|
36234
36252
|
try {
|
|
36235
36253
|
await client.query("BEGIN READ ONLY");
|
|
36236
36254
|
if (hooks.setup) await hooks.setup(client);
|
|
36237
|
-
const result = await runUserQueryBounded(client, sql, params, maxRows);
|
|
36255
|
+
const { result, viaCursor } = await runUserQueryBounded(client, sql, params, maxRows);
|
|
36238
36256
|
await client.query("ROLLBACK");
|
|
36239
36257
|
const typeNames = await safeResolveTypeNames(client, result.fields);
|
|
36240
|
-
return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
|
|
36258
|
+
return { ok: true, data: toQueryResult(result, maxRows, typeNames, viaCursor) };
|
|
36241
36259
|
} catch (err) {
|
|
36242
36260
|
try {
|
|
36243
36261
|
await client.query("ROLLBACK");
|
|
@@ -36265,10 +36283,10 @@ async function runReadWrite(sql, params = []) {
|
|
|
36265
36283
|
const maxRows = getMaxRows();
|
|
36266
36284
|
try {
|
|
36267
36285
|
await client.query("BEGIN");
|
|
36268
|
-
const result = await runUserQueryBounded(client, sql, params, maxRows);
|
|
36286
|
+
const { result, viaCursor } = await runUserQueryBounded(client, sql, params, maxRows);
|
|
36269
36287
|
await client.query("COMMIT");
|
|
36270
36288
|
const typeNames = await safeResolveTypeNames(client, result.fields);
|
|
36271
|
-
return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
|
|
36289
|
+
return { ok: true, data: toQueryResult(result, maxRows, typeNames, viaCursor) };
|
|
36272
36290
|
} catch (err) {
|
|
36273
36291
|
try {
|
|
36274
36292
|
await client.query("ROLLBACK");
|
|
@@ -36291,10 +36309,10 @@ async function runReadWriteRollback(sql, params = [], hooks = {}) {
|
|
|
36291
36309
|
try {
|
|
36292
36310
|
await client.query("BEGIN");
|
|
36293
36311
|
if (hooks.setup) await hooks.setup(client);
|
|
36294
|
-
const result = await runUserQueryBounded(client, sql, params, maxRows);
|
|
36312
|
+
const { result, viaCursor } = await runUserQueryBounded(client, sql, params, maxRows);
|
|
36295
36313
|
await client.query("ROLLBACK");
|
|
36296
36314
|
const typeNames = await safeResolveTypeNames(client, result.fields);
|
|
36297
|
-
return { ok: true, data: toQueryResult(result, maxRows, typeNames) };
|
|
36315
|
+
return { ok: true, data: toQueryResult(result, maxRows, typeNames, viaCursor) };
|
|
36298
36316
|
} catch (err) {
|
|
36299
36317
|
try {
|
|
36300
36318
|
await client.query("ROLLBACK");
|
|
@@ -36445,7 +36463,7 @@ var adminTools = [
|
|
|
36445
36463
|
limit: external_exports.number().int().min(1).max(100).default(50).describe("Max blocked/blocker pairs (default 50).")
|
|
36446
36464
|
}),
|
|
36447
36465
|
handler: async (input) => {
|
|
36448
|
-
const { limit } = input;
|
|
36466
|
+
const { limit = 50 } = input;
|
|
36449
36467
|
return runInternal(
|
|
36450
36468
|
`SELECT
|
|
36451
36469
|
blocked.pid AS blocked_pid,
|
|
@@ -36519,7 +36537,7 @@ var adminTools = [
|
|
|
36519
36537
|
includeSystem: external_exports.boolean().default(false).describe("If true, include built-in `pg_*` roles (pg_read_all_data, pg_monitor, etc.).")
|
|
36520
36538
|
}),
|
|
36521
36539
|
handler: async (input) => {
|
|
36522
|
-
const { includeSystem } = input;
|
|
36540
|
+
const { includeSystem = false } = input;
|
|
36523
36541
|
const filter = includeSystem ? "" : "WHERE NOT starts_with(r.rolname, 'pg_')";
|
|
36524
36542
|
return runInternal(
|
|
36525
36543
|
// Cast member_of to text[] so node-pg parses it into a JS array.
|
|
@@ -36560,7 +36578,7 @@ var adminTools = [
|
|
|
36560
36578
|
table: identSchema.optional().describe("Table name. Omit to list privileges for all tables in the schema.")
|
|
36561
36579
|
}),
|
|
36562
36580
|
handler: async (input) => {
|
|
36563
|
-
const { schema, table } = input;
|
|
36581
|
+
const { schema = "public", table } = input;
|
|
36564
36582
|
const tableFilter = table ? "AND table_name = $2" : "";
|
|
36565
36583
|
const params = [schema];
|
|
36566
36584
|
if (table) params.push(table);
|
|
@@ -36593,7 +36611,7 @@ var adminTools = [
|
|
|
36593
36611
|
mode: external_exports.enum(["cancel", "terminate"]).default("cancel").describe("`cancel` aborts the current query; `terminate` closes the connection entirely.")
|
|
36594
36612
|
}),
|
|
36595
36613
|
handler: async (input) => {
|
|
36596
|
-
const { pid, mode } = input;
|
|
36614
|
+
const { pid, mode = "cancel" } = input;
|
|
36597
36615
|
if (!isWritesAllowed()) {
|
|
36598
36616
|
return {
|
|
36599
36617
|
ok: false,
|
|
@@ -36691,7 +36709,7 @@ var adminTools = [
|
|
|
36691
36709
|
},
|
|
36692
36710
|
{
|
|
36693
36711
|
name: "pg_advisor",
|
|
36694
|
-
description: "Rolled-up DBA lint pass. One call returns three categories of findings:\n- sequence_exhaustion: SERIAL / BIGSERIAL / IDENTITY sequences whose `last_value` is above `seqExhaustionThreshold` of `max_value`. The classic incident class.\n- tables_without_primary_key: user tables with no PK. Bloat candidates and a sign of design drift; some replication setups also need PKs.\n- public_tables_without_rls: tables in `public` (or any schema in `rlsSchemas`) with row-level security disabled. Useful as a security baseline check.\nUse this as the 'what should I be looking at?' starting point, then drill into `pg_unused_indexes`, `pg_table_bloat`, `pg_seq_scan_tables` for the perf side.",
|
|
36712
|
+
description: "Rolled-up DBA lint pass. One call returns three categories of findings:\n- sequence_exhaustion: SERIAL / BIGSERIAL / IDENTITY sequences whose `last_value` is above `seqExhaustionThreshold` of `max_value`. The classic incident class.\n- tables_without_primary_key: user tables (plain and partitioned) with no PK defined. Bloat candidates and a sign of design drift; some replication setups also need PKs. Foreign tables are excluded -- PostgreSQL forbids declaring PKs on foreign tables.\n- public_tables_without_rls: tables in `public` (or any schema in `rlsSchemas`) with row-level security disabled. Useful as a security baseline check.\nUse this as the 'what should I be looking at?' starting point, then drill into `pg_unused_indexes`, `pg_table_bloat`, `pg_seq_scan_tables` for the perf side.",
|
|
36695
36713
|
annotations: {
|
|
36696
36714
|
title: "Database advisor (DBA lints)",
|
|
36697
36715
|
readOnlyHint: true,
|
|
@@ -36705,7 +36723,11 @@ var adminTools = [
|
|
|
36705
36723
|
limit: external_exports.number().int().min(1).max(500).default(50).describe("Max rows per category (default 50).")
|
|
36706
36724
|
}),
|
|
36707
36725
|
handler: async (input) => {
|
|
36708
|
-
const {
|
|
36726
|
+
const {
|
|
36727
|
+
seqExhaustionThreshold = 0.5,
|
|
36728
|
+
rlsSchemas = ["public"],
|
|
36729
|
+
limit = 50
|
|
36730
|
+
} = input;
|
|
36709
36731
|
return withSharedClient(async (run) => {
|
|
36710
36732
|
const [seqRes, noPkRes, rlsRes] = await Promise.all([
|
|
36711
36733
|
run(
|
|
@@ -36744,6 +36766,10 @@ var adminTools = [
|
|
|
36744
36766
|
// Partition children (relkind='r') inherit the parent's PK as an
|
|
36745
36767
|
// indisprimary index on the child, so the NOT EXISTS clause keeps
|
|
36746
36768
|
// already filtering them out.
|
|
36769
|
+
//
|
|
36770
|
+
// Foreign tables (relkind='f') are excluded: PostgreSQL forbids
|
|
36771
|
+
// PRIMARY KEY (and UNIQUE) constraints on foreign tables entirely,
|
|
36772
|
+
// so they would always appear here with no possible remediation.
|
|
36747
36773
|
`SELECT
|
|
36748
36774
|
n.nspname AS schema,
|
|
36749
36775
|
c.relname AS "table"
|
|
@@ -36792,7 +36818,7 @@ var adminTools = [
|
|
|
36792
36818
|
},
|
|
36793
36819
|
{
|
|
36794
36820
|
name: "pg_table_bloat",
|
|
36795
|
-
description: "Estimate table bloat (dead tuples + free space) for tables in a schema. Returns live tuples, dead tuples, dead-tuple ratio, last_vacuum / last_autovacuum timestamps, and total relation size. A high dead_ratio with a stale last_autovacuum is a sign a table needs VACUUM
|
|
36821
|
+
description: "Estimate table bloat (dead tuples + free space) for tables in a schema. Returns live tuples, dead tuples, dead-tuple ratio, last_vacuum / last_autovacuum timestamps, and total relation size. A high dead_ratio with a stale last_autovacuum is a sign a table needs VACUUM.\n\nThree methods are available via the `method` parameter:\n- `estimate` (default): reads pg_stat_user_tables -- fast, no extensions, ANALYZE-driven approximations. Use this first.\n- `approx`: uses pgstattuple_approx() -- fast sampling pass, more accurate than estimates, requires the pgstattuple extension.\n- `exact`: uses pgstattuple() -- full table scan, exact counts, slow on large tables, requires the pgstattuple extension. Always pass `schema` with method='exact' -- scanning all user tables in one statement will hit statement_timeout on non-trivial databases.\nInstall pgstattuple with `CREATE EXTENSION pgstattuple` (requires superuser).",
|
|
36796
36822
|
annotations: {
|
|
36797
36823
|
title: "Estimate table bloat",
|
|
36798
36824
|
readOnlyHint: true,
|
|
@@ -36803,13 +36829,59 @@ var adminTools = [
|
|
|
36803
36829
|
inputSchema: external_exports.object({
|
|
36804
36830
|
schema: identSchema.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
36805
36831
|
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%."),
|
|
36806
|
-
limit: external_exports.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
|
|
36832
|
+
limit: external_exports.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50)."),
|
|
36833
|
+
method: external_exports.enum(["estimate", "approx", "exact"]).default("estimate").describe(
|
|
36834
|
+
"Bloat measurement method. 'estimate' (default) uses pg_stat_user_tables (fast, no extensions). 'approx' uses pgstattuple_approx() (fast sampling, more accurate). 'exact' uses pgstattuple() (full scan, exact but slow). Both 'approx' and 'exact' require the pgstattuple extension."
|
|
36835
|
+
)
|
|
36807
36836
|
}),
|
|
36808
36837
|
handler: async (input) => {
|
|
36809
|
-
const {
|
|
36838
|
+
const {
|
|
36839
|
+
schema,
|
|
36840
|
+
minDeadRatio = 0.1,
|
|
36841
|
+
limit = 50,
|
|
36842
|
+
method = "estimate"
|
|
36843
|
+
} = input;
|
|
36810
36844
|
const schemaFilter = schema ? "AND schemaname = $3" : "AND schemaname NOT IN ('pg_catalog', 'information_schema') AND schemaname NOT LIKE 'pg_%'";
|
|
36811
36845
|
const params = [minDeadRatio, limit];
|
|
36812
36846
|
if (schema) params.push(schema);
|
|
36847
|
+
if (method !== "estimate") {
|
|
36848
|
+
const check2 = await runInternal(
|
|
36849
|
+
`SELECT EXISTS (
|
|
36850
|
+
SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pgstattuple'
|
|
36851
|
+
) AS installed`
|
|
36852
|
+
);
|
|
36853
|
+
if (!check2.ok) return check2;
|
|
36854
|
+
if (!check2.data?.[0]?.installed) {
|
|
36855
|
+
return {
|
|
36856
|
+
ok: false,
|
|
36857
|
+
error: `pgstattuple extension is not installed. Install with \`CREATE EXTENSION pgstattuple;\` (requires superuser), then retry with method='${method}'.`
|
|
36858
|
+
};
|
|
36859
|
+
}
|
|
36860
|
+
const fn = method === "approx" ? "pgstattuple_approx" : "pgstattuple";
|
|
36861
|
+
const liveTuplesCol = method === "approx" ? "approx_tuple_count" : "tuple_count";
|
|
36862
|
+
return runInternal(
|
|
36863
|
+
`SELECT
|
|
36864
|
+
s.schemaname AS schema,
|
|
36865
|
+
s.relname AS "table",
|
|
36866
|
+
(p.${liveTuplesCol})::text AS live_tuples,
|
|
36867
|
+
p.dead_tuple_count::text AS dead_tuples,
|
|
36868
|
+
(p.dead_tuple_count::float8 / NULLIF(p.${liveTuplesCol} + p.dead_tuple_count, 0))::numeric(6, 3)::float8 AS dead_ratio,
|
|
36869
|
+
pg_size_pretty(pg_total_relation_size(s.relid)) AS size_pretty,
|
|
36870
|
+
pg_total_relation_size(s.relid)::text AS size_bytes,
|
|
36871
|
+
s.last_vacuum::text AS last_vacuum,
|
|
36872
|
+
s.last_autovacuum::text AS last_autovacuum,
|
|
36873
|
+
s.last_analyze::text AS last_analyze
|
|
36874
|
+
FROM pg_catalog.pg_stat_user_tables s
|
|
36875
|
+
JOIN pg_catalog.pg_class c ON c.oid = s.relid AND c.relkind IN ('r', 'm')
|
|
36876
|
+
CROSS JOIN LATERAL ${fn}(s.relid::regclass) p
|
|
36877
|
+
WHERE (p.${liveTuplesCol} + p.dead_tuple_count) > 0
|
|
36878
|
+
AND (p.dead_tuple_count::float8 / NULLIF(p.${liveTuplesCol} + p.dead_tuple_count, 0)) >= $1
|
|
36879
|
+
${schemaFilter}
|
|
36880
|
+
ORDER BY p.dead_tuple_count DESC
|
|
36881
|
+
LIMIT $2`,
|
|
36882
|
+
params
|
|
36883
|
+
);
|
|
36884
|
+
}
|
|
36813
36885
|
return runInternal(
|
|
36814
36886
|
// dead_ratio = dead / (live + dead): bounded [0, 1]. A 100%-dead table
|
|
36815
36887
|
// (live=0, dead>0) correctly reports 1.0 instead of 0. Tables with both
|
|
@@ -36855,14 +36927,14 @@ function quoteQualifiedTable(name) {
|
|
|
36855
36927
|
return name.split(".").map((p) => quoteIdent(p)).join(".");
|
|
36856
36928
|
}
|
|
36857
36929
|
function validateHypoIndex(idx) {
|
|
36930
|
+
if (idx.table.includes('"')) {
|
|
36931
|
+
return `Hypothetical index table ${JSON.stringify(idx.table)} contains a double-quote; pass plain identifier names without pre-quoting.`;
|
|
36932
|
+
}
|
|
36858
36933
|
const pieces = idx.table.split(".");
|
|
36859
36934
|
if (pieces.length > 2) {
|
|
36860
36935
|
return `Hypothetical index table ${JSON.stringify(idx.table)} is over-qualified; use only \`schema.table\` or \`table\`.`;
|
|
36861
36936
|
}
|
|
36862
36937
|
for (const piece of pieces) {
|
|
36863
|
-
if (piece.includes('"')) {
|
|
36864
|
-
return `Hypothetical index table ${JSON.stringify(idx.table)} contains a double-quote; pass plain identifier names without pre-quoting.`;
|
|
36865
|
-
}
|
|
36866
36938
|
if (Buffer.byteLength(piece, "utf8") > 63) {
|
|
36867
36939
|
return `Hypothetical index table piece ${JSON.stringify(piece)} exceeds PostgreSQL's 63-byte NAMEDATALEN limit (multi-byte characters count as multiple bytes).`;
|
|
36868
36940
|
}
|
|
@@ -36922,13 +36994,11 @@ var explainTools = [
|
|
|
36922
36994
|
handler: async (input) => {
|
|
36923
36995
|
const {
|
|
36924
36996
|
sql,
|
|
36925
|
-
analyze
|
|
36926
|
-
format
|
|
36997
|
+
analyze = false,
|
|
36998
|
+
format = "text",
|
|
36927
36999
|
params,
|
|
36928
37000
|
hypothetical_indexes
|
|
36929
37001
|
} = input;
|
|
36930
|
-
const analyze = rawAnalyze ?? false;
|
|
36931
|
-
const format = rawFormat ?? "text";
|
|
36932
37002
|
if (/^\s*EXPLAIN\b/i.test(sql)) {
|
|
36933
37003
|
return {
|
|
36934
37004
|
ok: false,
|
|
@@ -36995,7 +37065,7 @@ var healthTools = [
|
|
|
36995
37065
|
activeQueryLimit: external_exports.number().int().min(1).max(100).default(10).describe("Max active queries to return (default 10, max 100).")
|
|
36996
37066
|
}),
|
|
36997
37067
|
handler: async (input) => {
|
|
36998
|
-
const { activeQueryLimit } = input;
|
|
37068
|
+
const { activeQueryLimit = 10 } = input;
|
|
36999
37069
|
return withSharedClient(async (run) => {
|
|
37000
37070
|
const [versionRes, sizeRes, connsRes, activeRes, tableCountRes] = await Promise.all([
|
|
37001
37071
|
run(`SELECT version() AS version`),
|
|
@@ -37070,7 +37140,18 @@ var healthTools = [
|
|
|
37070
37140
|
var queryTools = [
|
|
37071
37141
|
{
|
|
37072
37142
|
name: "pg_readonly",
|
|
37073
|
-
description: "Run a SQL statement
|
|
37143
|
+
description: "Run a SQL statement with no persistent data changes. Always executes inside a `BEGIN READ ONLY` transaction regardless of `ALLOW_WRITES`, so postgres itself rejects any INSERT/UPDATE/DELETE/DDL and the transaction is always rolled back. Use this whenever the goal is to read - SELECT, EXPLAIN, SHOW, VALUES, WITH ... SELECT, etc. Scope caveat for hosts that auto-allow this tool: `READ ONLY` constrains writes to the DATABASE, not every side effect. Functions whose effect is outside the table data - `pg_cancel_backend` / `pg_terminate_backend`, `pg_read_file`, `lo_export`, `COPY ... TO PROGRAM` - are NOT blocked here and are NOT behind the `ALLOW_WRITES` gate that `pg_kill` sits behind. They still require the privileges the `DATABASE_URL` role holds, so a least-privileged role (e.g. `pg_read_all_data`) is what actually bounds this tool. 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.",
|
|
37144
|
+
// DELIBERATE, do not "fix" to match the caveat in the description above.
|
|
37145
|
+
// `BEGIN READ ONLY` does not block side-effecting functions
|
|
37146
|
+
// (pg_terminate_backend, pg_read_file, COPY ... TO PROGRAM), so these
|
|
37147
|
+
// hints are arguably too generous, and a review pass will keep noticing
|
|
37148
|
+
// that. The decision is to keep them: staying in the host auto-allow class
|
|
37149
|
+
// is the entire reason pg_readonly exists as a separate tool from
|
|
37150
|
+
// pg_query, and the DATABASE_URL role -- not the transaction mode -- is
|
|
37151
|
+
// the control that actually bounds this tool. The description and the
|
|
37152
|
+
// README carry the caveat; a least-privileged role is the enforcement.
|
|
37153
|
+
// Flipping these to destructive would move pg_readonly to "always prompt"
|
|
37154
|
+
// in every existing host config for a bound the role already provides.
|
|
37074
37155
|
annotations: {
|
|
37075
37156
|
title: "Run read-only SQL",
|
|
37076
37157
|
readOnlyHint: true,
|
|
@@ -37140,7 +37221,7 @@ var schemaTools = [
|
|
|
37140
37221
|
},
|
|
37141
37222
|
{
|
|
37142
37223
|
name: "pg_list_tables",
|
|
37143
|
-
description: "List tables (and optionally views) in a schema. Returns name, type (table/view/materialized view/foreign), and estimated row count (from `reltuples`;
|
|
37224
|
+
description: "List tables (and optionally views) in a schema. Returns name, type (table/view/materialized view/foreign), and estimated row count (from `reltuples`; null = no ANALYZE yet on PG 14+; 0 may mean empty or unanalyzed on PG <= 13). Paginate via `limit`/`offset` on very large schemas.",
|
|
37144
37225
|
annotations: {
|
|
37145
37226
|
title: "List tables in a schema",
|
|
37146
37227
|
readOnlyHint: true,
|
|
@@ -37155,7 +37236,12 @@ var schemaTools = [
|
|
|
37155
37236
|
offset: external_exports.number().int().min(0).default(0).describe("Rows to skip for pagination (default 0).")
|
|
37156
37237
|
}),
|
|
37157
37238
|
handler: async (input) => {
|
|
37158
|
-
const {
|
|
37239
|
+
const {
|
|
37240
|
+
schema = "public",
|
|
37241
|
+
includeViews = false,
|
|
37242
|
+
limit = 500,
|
|
37243
|
+
offset = 0
|
|
37244
|
+
} = input;
|
|
37159
37245
|
const kinds = includeViews ? "('r', 'v', 'm', 'f', 'p')" : "('r', 'f', 'p')";
|
|
37160
37246
|
return runInternal(
|
|
37161
37247
|
`SELECT
|
|
@@ -37168,7 +37254,7 @@ var schemaTools = [
|
|
|
37168
37254
|
WHEN 'p' THEN 'partitioned_table'
|
|
37169
37255
|
ELSE c.relkind::text
|
|
37170
37256
|
END AS type,
|
|
37171
|
-
c.reltuples::
|
|
37257
|
+
NULLIF(round(c.reltuples), -1)::float8 AS estimated_rows
|
|
37172
37258
|
FROM pg_catalog.pg_class c
|
|
37173
37259
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
37174
37260
|
WHERE n.nspname = $1
|
|
@@ -37194,7 +37280,7 @@ var schemaTools = [
|
|
|
37194
37280
|
table: identSchema.describe("Table name.")
|
|
37195
37281
|
}),
|
|
37196
37282
|
handler: async (input) => {
|
|
37197
|
-
const { schema, table } = input;
|
|
37283
|
+
const { schema = "public", table } = input;
|
|
37198
37284
|
const kindQuery = `
|
|
37199
37285
|
SELECT
|
|
37200
37286
|
CASE c.relkind
|
|
@@ -37231,11 +37317,12 @@ var schemaTools = [
|
|
|
37231
37317
|
FROM pg_catalog.pg_index i
|
|
37232
37318
|
JOIN pg_catalog.pg_class c ON c.oid = i.indrelid
|
|
37233
37319
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
37234
|
-
JOIN
|
|
37320
|
+
JOIN LATERAL unnest(i.indkey[0:i.indnkeyatts - 1]) WITH ORDINALITY AS k(attnum, ord) ON TRUE
|
|
37321
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum
|
|
37235
37322
|
WHERE n.nspname = $1
|
|
37236
37323
|
AND c.relname = $2
|
|
37237
37324
|
AND i.indisprimary
|
|
37238
|
-
ORDER BY
|
|
37325
|
+
ORDER BY k.ord
|
|
37239
37326
|
`;
|
|
37240
37327
|
const foreignKeysQuery = `
|
|
37241
37328
|
SELECT
|
|
@@ -37415,7 +37502,7 @@ var schemaTools = [
|
|
|
37415
37502
|
includeMaterialized: external_exports.boolean().default(true).describe("If true, include materialized views.")
|
|
37416
37503
|
}),
|
|
37417
37504
|
handler: async (input) => {
|
|
37418
|
-
const { schema, includeMaterialized } = input;
|
|
37505
|
+
const { schema = "public", includeMaterialized = true } = input;
|
|
37419
37506
|
const kinds = includeMaterialized ? "('v', 'm')" : "('v')";
|
|
37420
37507
|
return runInternal(
|
|
37421
37508
|
`SELECT
|
|
@@ -37445,7 +37532,7 @@ var schemaTools = [
|
|
|
37445
37532
|
schema: identSchema.default("public").describe("Schema name (defaults to 'public').")
|
|
37446
37533
|
}),
|
|
37447
37534
|
handler: async (input) => {
|
|
37448
|
-
const { schema } = input;
|
|
37535
|
+
const { schema = "public" } = input;
|
|
37449
37536
|
return runInternal(
|
|
37450
37537
|
`SELECT
|
|
37451
37538
|
p.proname AS name,
|
|
@@ -37509,7 +37596,7 @@ var schemaTools = [
|
|
|
37509
37596
|
limit: external_exports.number().int().min(1).max(1e3).default(100).describe("Max rows to return (default 100).")
|
|
37510
37597
|
}),
|
|
37511
37598
|
handler: async (input) => {
|
|
37512
|
-
const { pattern, schema, limit } = input;
|
|
37599
|
+
const { pattern, schema, limit = 100 } = input;
|
|
37513
37600
|
const schemaFilter = schema ? "AND n.nspname = $3" : "AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'";
|
|
37514
37601
|
const params = [pattern, limit];
|
|
37515
37602
|
if (schema) params.push(schema);
|
|
@@ -37540,7 +37627,7 @@ var schemaTools = [
|
|
|
37540
37627
|
var statsTools = [
|
|
37541
37628
|
{
|
|
37542
37629
|
name: "pg_top_queries",
|
|
37543
|
-
description: "Top N queries by total or mean execution time. Requires the `pg_stat_statements` extension to be installed and enabled (most managed Postgres providers have it on by default). Returns normalized query text (constants replaced with `?`), call count, total/mean/min/max time in ms, rows returned, and cache hit ratio. Use this to find slow queries worth optimizing.",
|
|
37630
|
+
description: "Top N queries by total or mean execution time. Requires the `pg_stat_statements` extension to be installed and enabled (most managed Postgres providers have it on by default). Returns normalized query text (constants replaced with `?`), call count, total/mean/min/max time in ms, rows returned, and cache hit ratio. Use this to find slow queries worth optimizing. On pg_stat_statements >= 1.10 (Postgres 15+), also returns `io_read_time_ms` and `io_write_time_ms` to separate IO-bound from CPU-bound queries (null when track_io_timing = off or the query did no measurable IO -- enable track_io_timing in postgresql.conf to get non-null values). Scoped to the database in DATABASE_URL: pg_stat_statements is cluster-wide, so results are filtered by `dbid` to match every other tool here rather than leaking query text from unrelated databases sharing the cluster.",
|
|
37544
37631
|
annotations: {
|
|
37545
37632
|
title: "Top queries by execution time",
|
|
37546
37633
|
readOnlyHint: true,
|
|
@@ -37553,7 +37640,7 @@ var statsTools = [
|
|
|
37553
37640
|
limit: external_exports.number().int().min(1).max(100).default(20).describe("Number of rows to return (default 20).")
|
|
37554
37641
|
}),
|
|
37555
37642
|
handler: async (input) => {
|
|
37556
|
-
const { orderBy, limit } = input;
|
|
37643
|
+
const { orderBy = "total_time", limit = 20 } = input;
|
|
37557
37644
|
const versionRes = await runInternal(
|
|
37558
37645
|
`SELECT extversion AS version FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'`
|
|
37559
37646
|
);
|
|
@@ -37570,6 +37657,11 @@ var statsTools = [
|
|
|
37570
37657
|
const meanCol = useExecSuffix ? "mean_exec_time" : "mean_time";
|
|
37571
37658
|
const minCol = useExecSuffix ? "min_exec_time" : "min_time";
|
|
37572
37659
|
const maxCol = useExecSuffix ? "max_exec_time" : "max_time";
|
|
37660
|
+
const hasIoTiming = compareVersions(extVersion, "1.10") >= 0;
|
|
37661
|
+
const hasSharedBlkCols = compareVersions(extVersion, "1.11") >= 0;
|
|
37662
|
+
const ioTimingCols = hasIoTiming ? `,
|
|
37663
|
+
NULLIF(${hasSharedBlkCols ? "shared_blk_read_time" : "blk_read_time"}, 0)::numeric(18, 2)::float8 AS io_read_time_ms,
|
|
37664
|
+
NULLIF(${hasSharedBlkCols ? "shared_blk_write_time" : "blk_write_time"}, 0)::numeric(18, 2)::float8 AS io_write_time_ms` : "";
|
|
37573
37665
|
const orderCol = orderBy === "total_time" ? totalCol : orderBy === "mean_time" ? meanCol : "pg_stat_statements.calls";
|
|
37574
37666
|
return runInternal(
|
|
37575
37667
|
// bigint counters (calls, rows) come back as `.text` for lossless
|
|
@@ -37588,8 +37680,9 @@ var statsTools = [
|
|
|
37588
37680
|
WHEN (shared_blks_hit + shared_blks_read) > 0
|
|
37589
37681
|
THEN (shared_blks_hit::float8 / (shared_blks_hit + shared_blks_read) * 100)::numeric(5, 2)::float8
|
|
37590
37682
|
ELSE NULL
|
|
37591
|
-
END AS hit_percent
|
|
37683
|
+
END AS hit_percent${ioTimingCols}
|
|
37592
37684
|
FROM pg_stat_statements
|
|
37685
|
+
WHERE dbid = (SELECT oid FROM pg_catalog.pg_database WHERE datname = current_database())
|
|
37593
37686
|
ORDER BY ${orderCol} DESC NULLS LAST
|
|
37594
37687
|
LIMIT $1`,
|
|
37595
37688
|
[limit]
|
|
@@ -37612,7 +37705,11 @@ var statsTools = [
|
|
|
37612
37705
|
limit: external_exports.number().int().min(1).max(100).default(20).describe("Max rows to return (default 20).")
|
|
37613
37706
|
}),
|
|
37614
37707
|
handler: async (input) => {
|
|
37615
|
-
const {
|
|
37708
|
+
const {
|
|
37709
|
+
schema,
|
|
37710
|
+
minSize = 1e3,
|
|
37711
|
+
limit = 20
|
|
37712
|
+
} = input;
|
|
37616
37713
|
const schemaFilter = schema ? "AND schemaname = $3" : "AND schemaname NOT IN ('pg_catalog', 'information_schema') AND schemaname NOT LIKE 'pg_%'";
|
|
37617
37714
|
const params = [minSize, limit];
|
|
37618
37715
|
if (schema) params.push(schema);
|
|
@@ -37653,7 +37750,11 @@ var statsTools = [
|
|
|
37653
37750
|
limit: external_exports.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
|
|
37654
37751
|
}),
|
|
37655
37752
|
handler: async (input) => {
|
|
37656
|
-
const {
|
|
37753
|
+
const {
|
|
37754
|
+
schema,
|
|
37755
|
+
maxScans = 10,
|
|
37756
|
+
limit = 50
|
|
37757
|
+
} = input;
|
|
37657
37758
|
const schemaFilter = schema ? "AND s.schemaname = $3" : "AND s.schemaname NOT IN ('pg_catalog', 'information_schema') AND s.schemaname NOT LIKE 'pg_%'";
|
|
37658
37759
|
const params = [maxScans, limit];
|
|
37659
37760
|
if (schema) params.push(schema);
|
|
@@ -37695,12 +37796,25 @@ function compareVersions(a, b) {
|
|
|
37695
37796
|
}
|
|
37696
37797
|
|
|
37697
37798
|
// src/index.ts
|
|
37698
|
-
var version2 = true ? "0.
|
|
37799
|
+
var version2 = true ? "0.8.0" : await readPackageVersion();
|
|
37699
37800
|
var subcommand = process.argv[2];
|
|
37700
37801
|
if (subcommand === "version" || subcommand === "--version") {
|
|
37701
37802
|
console.log(version2);
|
|
37702
37803
|
process.exit(0);
|
|
37703
37804
|
}
|
|
37805
|
+
if (subcommand !== void 0 && !subcommand.startsWith("-")) {
|
|
37806
|
+
const looksLikeDsn = /^postgres(ql)?:\/\//i.test(subcommand);
|
|
37807
|
+
const message = looksLikeDsn ? `postgres-mcp: connection strings are not accepted as an argument.
|
|
37808
|
+
Set DATABASE_URL in the MCP server env instead:
|
|
37809
|
+
"env": { "DATABASE_URL": "postgres://..." }
|
|
37810
|
+
` : `postgres-mcp: unknown subcommand '${subcommand}'
|
|
37811
|
+
Usage:
|
|
37812
|
+
postgres-mcp start the MCP server on stdio
|
|
37813
|
+
postgres-mcp version print the version and exit
|
|
37814
|
+
`;
|
|
37815
|
+
writeSync(2, message);
|
|
37816
|
+
process.exit(1);
|
|
37817
|
+
}
|
|
37704
37818
|
var allTools = [...queryTools, ...schemaTools, ...explainTools, ...healthTools, ...statsTools, ...adminTools];
|
|
37705
37819
|
var server = new McpServer({
|
|
37706
37820
|
name: "@yawlabs/postgres-mcp",
|
|
@@ -37716,9 +37830,14 @@ for (const tool of allTools) {
|
|
|
37716
37830
|
);
|
|
37717
37831
|
}
|
|
37718
37832
|
var transport = new StdioServerTransport();
|
|
37719
|
-
|
|
37720
|
-
|
|
37721
|
-
console.error(`@yawlabs/postgres-mcp v${version2} ready (${allTools.length} tools, ${writesNote})`);
|
|
37833
|
+
server.connect(transport).then(() => {
|
|
37834
|
+
const writesNote = isWritesAllowed() ? "writes ENABLED" : "read-only";
|
|
37835
|
+
console.error(`@yawlabs/postgres-mcp v${version2} ready (${allTools.length} tools, ${writesNote})`);
|
|
37836
|
+
}).catch((err) => {
|
|
37837
|
+
process.stderr.write(`postgres-mcp: ${err instanceof Error ? err.message : String(err)}
|
|
37838
|
+
`);
|
|
37839
|
+
process.exit(1);
|
|
37840
|
+
});
|
|
37722
37841
|
var exiting = false;
|
|
37723
37842
|
var cleanup = async () => {
|
|
37724
37843
|
if (exiting) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/postgres-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"@types/pg": "^8.20.0",
|
|
55
55
|
"esbuild": "^0.28.0",
|
|
56
56
|
"pg": "^8.14.0",
|
|
57
|
+
"postject": "^1.0.0-alpha.6",
|
|
57
58
|
"typescript": "^6.0.3",
|
|
58
59
|
"zod": "^4.3.6"
|
|
59
60
|
},
|