@yawlabs/postgres-mcp 0.1.1 → 0.3.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/README.md +62 -2
- package/dist/index.js +609 -30
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -75,16 +75,41 @@ Read-only is the default. If you want the agent to be able to `INSERT`, `UPDATE`
|
|
|
75
75
|
|
|
76
76
|
Prefer scoping this to dev/test databases — for production, leave writes off and use migration tools out-of-band.
|
|
77
77
|
|
|
78
|
+
## What can an agent do with this?
|
|
79
|
+
|
|
80
|
+
Once connected, the agent picks tools automatically based on what you ask. A few real examples:
|
|
81
|
+
|
|
82
|
+
- **"Describe the users table"** -> `pg_describe_table` -> returns columns, PK, FKs, indexes.
|
|
83
|
+
- **"Which tables have a `user_id` column?"** -> `pg_search_columns` with pattern `user_id` -> one call instead of iterating every table.
|
|
84
|
+
- **"This query is slow, why?"** -> `pg_explain` with `analyze: true` -> returns the plan with actual row counts and timing.
|
|
85
|
+
- **"What's the slowest query we run?"** -> `pg_top_queries` -> returns the top N from `pg_stat_statements` with mean/total/min/max times.
|
|
86
|
+
- **"Why is my app hanging?"** -> `pg_inspect_locks` -> returns blocked PIDs and the queries holding their locks; follow up with `pg_kill` (with `ALLOW_WRITES=1`) to cancel the blocker.
|
|
87
|
+
- **"Do we have any unused indexes?"** -> `pg_unused_indexes` -> returns non-unique, non-primary indexes with zero or low scan counts + their size.
|
|
88
|
+
- **"Is `pgvector` installed?"** -> `pg_list_extensions` -> yes/no with version.
|
|
89
|
+
|
|
78
90
|
## Tools
|
|
79
91
|
|
|
80
92
|
| Tool | Description |
|
|
81
93
|
|------|-------------|
|
|
82
94
|
| `pg_query` | Run a SQL query. Read-only by default; writes require `ALLOW_WRITES=1`. Supports parameterized queries via `params`. |
|
|
83
95
|
| `pg_list_schemas` | List non-system schemas. |
|
|
84
|
-
| `pg_list_tables` | List tables (and optionally views) in a schema with estimated row counts. |
|
|
96
|
+
| `pg_list_tables` | List tables (and optionally views) in a schema with estimated row counts. Paginated via `limit`/`offset`. |
|
|
85
97
|
| `pg_describe_table` | Columns, primary key, foreign keys, and indexes for a table. |
|
|
98
|
+
| `pg_list_views` | List views and materialized views in a schema, including their SQL definitions. |
|
|
99
|
+
| `pg_list_functions` | List functions, procedures, and aggregates in a schema with signatures and return types. |
|
|
100
|
+
| `pg_list_extensions` | List installed extensions (pgvector, postgis, pg_stat_statements, etc.) with versions. |
|
|
101
|
+
| `pg_search_columns` | Find columns by name pattern across all user schemas. Case-insensitive, supports SQL LIKE wildcards. |
|
|
86
102
|
| `pg_explain` | `EXPLAIN` or `EXPLAIN ANALYZE` for a SQL statement. Text or JSON output. |
|
|
87
103
|
| `pg_health` | Server version, database size, connection count, active queries, table count. |
|
|
104
|
+
| `pg_top_queries` | Top N queries by total/mean execution time. Requires the `pg_stat_statements` extension. |
|
|
105
|
+
| `pg_seq_scan_tables` | Tables with heavy sequential scans — missing-index candidates. |
|
|
106
|
+
| `pg_unused_indexes` | Non-unique, non-primary indexes with low scan counts — drop candidates. |
|
|
107
|
+
| `pg_inspect_locks` | Who is blocking whom right now (blocked PID, blocker PID, lock type, queries). |
|
|
108
|
+
| `pg_list_roles` | Database roles with login/superuser/createdb flags and group memberships. |
|
|
109
|
+
| `pg_table_privileges` | Who has SELECT/INSERT/UPDATE/DELETE/etc. on a table or whole schema. |
|
|
110
|
+
| `pg_table_bloat` | Tables with high dead-tuple ratios — VACUUM candidates. |
|
|
111
|
+
| `pg_replication_status` | Replication slots, connected replicas, and current WAL position. |
|
|
112
|
+
| `pg_kill` | Cancel a running query or terminate a backend connection. Requires `ALLOW_WRITES=1`. |
|
|
88
113
|
|
|
89
114
|
## Configuration
|
|
90
115
|
|
|
@@ -96,8 +121,43 @@ All env vars are read from the MCP server's environment:
|
|
|
96
121
|
| `ALLOW_WRITES` | unset | Set to `1` or `true` to allow DML/DDL via `pg_query` and `pg_explain` ANALYZE of writes. |
|
|
97
122
|
| `POSTGRES_STATEMENT_TIMEOUT_MS` | `30000` | Per-statement timeout. |
|
|
98
123
|
| `POSTGRES_MAX_ROWS` | `1000` | Cap on rows returned by `pg_query`. |
|
|
124
|
+
| `POSTGRES_POOL_MAX` | `5` | Max pool connections. Set to `1` for single-threaded backends (pglite-socket, PgBouncer transaction mode). |
|
|
125
|
+
| `POSTGRES_SSL_REJECT_UNAUTHORIZED` | unset | Set to `false` to skip TLS cert verification (for managed DBs using private-CA certs). Connection is still encrypted. |
|
|
126
|
+
|
|
127
|
+
### Connecting to managed Postgres (Supabase, Neon, RDS, etc.)
|
|
128
|
+
|
|
129
|
+
Most managed databases require TLS but serve certs signed by a private CA that Node's default trust store doesn't recognize. The symptom is one of:
|
|
130
|
+
|
|
131
|
+
- `self signed certificate in certificate chain`
|
|
132
|
+
- `unable to get local issuer certificate`
|
|
133
|
+
- `unable to verify the first certificate`
|
|
134
|
+
|
|
135
|
+
To allow the connection while keeping traffic encrypted, add `POSTGRES_SSL_REJECT_UNAUTHORIZED=false` to the `env` block:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
"env": {
|
|
139
|
+
"DATABASE_URL": "postgres://user:pass@host:5432/db?sslmode=require",
|
|
140
|
+
"POSTGRES_SSL_REJECT_UNAUTHORIZED": "false"
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
This disables certificate chain verification only -- the TCP connection is still TLS-encrypted end-to-end. For production setups where you can install the CA, prefer putting the cert in the Node trust store (`NODE_EXTRA_CA_CERTS`) over disabling verification globally.
|
|
145
|
+
|
|
146
|
+
## Troubleshooting
|
|
147
|
+
|
|
148
|
+
**`DATABASE_URL is not set`** — Your MCP client is launching the server without the env var. On Windows especially, env vars set in bash / PowerShell profiles are not inherited by MCP servers launched via `cmd`. Put `DATABASE_URL` directly in the `env` block of `.mcp.json`.
|
|
149
|
+
|
|
150
|
+
**`password authentication failed`** — Check the username, password, and that the user has `CONNECT` privilege on the database. URL-encode special characters in the password (`@` → `%40`, `#` → `%23`, `/` → `%2F`).
|
|
151
|
+
|
|
152
|
+
**`SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string`** — The password in your connection string is empty or became `null` after URL decoding. Re-check your connection string.
|
|
153
|
+
|
|
154
|
+
**`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.
|
|
155
|
+
|
|
156
|
+
**`Write blocked: this server is in read-only mode`** — You asked the agent to write but `ALLOW_WRITES` is not set. Add `ALLOW_WRITES=1` to the `env` block of `.mcp.json` and restart your MCP client. Only do this for dev/test DBs.
|
|
157
|
+
|
|
158
|
+
**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.
|
|
99
159
|
|
|
100
|
-
|
|
160
|
+
**First query is slow, subsequent queries are fast** — Expected. The pg driver lazily establishes the first connection; subsequent queries reuse the pool.
|
|
101
161
|
|
|
102
162
|
## License
|
|
103
163
|
|
package/dist/index.js
CHANGED
|
@@ -35271,13 +35271,25 @@ function isWritesAllowed() {
|
|
|
35271
35271
|
const v = process.env.ALLOW_WRITES;
|
|
35272
35272
|
return v === "1" || v === "true";
|
|
35273
35273
|
}
|
|
35274
|
+
function getSslConfig() {
|
|
35275
|
+
const raw = process.env.POSTGRES_SSL_REJECT_UNAUTHORIZED;
|
|
35276
|
+
if (raw === void 0) return void 0;
|
|
35277
|
+
if (raw === "0" || raw === "false") return { rejectUnauthorized: false };
|
|
35278
|
+
if (raw === "1" || raw === "true") return { rejectUnauthorized: true };
|
|
35279
|
+
return void 0;
|
|
35280
|
+
}
|
|
35274
35281
|
function getPool() {
|
|
35275
35282
|
if (pool) return pool;
|
|
35283
|
+
const ssl = getSslConfig();
|
|
35276
35284
|
pool = new esm_default.Pool({
|
|
35277
35285
|
connectionString: getDatabaseUrl(),
|
|
35278
35286
|
statement_timeout: getStatementTimeoutMs(),
|
|
35279
35287
|
max: getPoolMax(),
|
|
35280
|
-
|
|
35288
|
+
// MCP sessions can have minutes-long gaps between tool calls. A short
|
|
35289
|
+
// idleTimeout forces a reconnect on every tool call. 60s keeps the pool
|
|
35290
|
+
// warm without holding connections indefinitely.
|
|
35291
|
+
idleTimeoutMillis: 6e4,
|
|
35292
|
+
...ssl ? { ssl } : {}
|
|
35281
35293
|
});
|
|
35282
35294
|
pool.on("error", (err) => {
|
|
35283
35295
|
console.error(`[postgres-mcp] pool error: ${err.message}`);
|
|
@@ -35364,11 +35376,273 @@ async function shutdown() {
|
|
|
35364
35376
|
}
|
|
35365
35377
|
}
|
|
35366
35378
|
|
|
35379
|
+
// src/tools/admin.ts
|
|
35380
|
+
var adminTools = [
|
|
35381
|
+
{
|
|
35382
|
+
name: "pg_inspect_locks",
|
|
35383
|
+
description: "Show current lock contention: which sessions are blocked and who is blocking them. Returns blocked PID, blocking PID, lock types, relation being contested, and the queries involved. Use this first when a tool call hangs or the app feels stuck \u2014 it's the fastest way to identify a long-held transaction holding a lock.",
|
|
35384
|
+
annotations: {
|
|
35385
|
+
title: "Inspect blocking locks",
|
|
35386
|
+
readOnlyHint: true,
|
|
35387
|
+
destructiveHint: false,
|
|
35388
|
+
idempotentHint: true,
|
|
35389
|
+
openWorldHint: true
|
|
35390
|
+
},
|
|
35391
|
+
inputSchema: external_exports3.object({
|
|
35392
|
+
limit: external_exports3.number().int().min(1).max(100).default(50).describe("Max blocked/blocker pairs (default 50).")
|
|
35393
|
+
}),
|
|
35394
|
+
handler: async (input) => {
|
|
35395
|
+
const { limit } = input;
|
|
35396
|
+
return runInternal(
|
|
35397
|
+
`SELECT
|
|
35398
|
+
blocked.pid AS blocked_pid,
|
|
35399
|
+
blocked.usename AS blocked_user,
|
|
35400
|
+
blocked.query AS blocked_query,
|
|
35401
|
+
EXTRACT(EPOCH FROM (now() - blocked.query_start))::numeric(10, 2)::float8 AS blocked_duration_seconds,
|
|
35402
|
+
blocking.pid AS blocking_pid,
|
|
35403
|
+
blocking.usename AS blocking_user,
|
|
35404
|
+
blocking.query AS blocking_query,
|
|
35405
|
+
blocking.state AS blocking_state,
|
|
35406
|
+
EXTRACT(EPOCH FROM (now() - blocking.query_start))::numeric(10, 2)::float8 AS blocking_duration_seconds,
|
|
35407
|
+
CASE
|
|
35408
|
+
WHEN bl.relation IS NOT NULL
|
|
35409
|
+
THEN (SELECT n.nspname || '.' || c.relname
|
|
35410
|
+
FROM pg_catalog.pg_class c
|
|
35411
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
35412
|
+
WHERE c.oid = bl.relation)
|
|
35413
|
+
ELSE NULL
|
|
35414
|
+
END AS relation,
|
|
35415
|
+
bl.locktype AS lock_type
|
|
35416
|
+
FROM pg_catalog.pg_locks bl
|
|
35417
|
+
JOIN pg_catalog.pg_stat_activity blocked ON blocked.pid = bl.pid
|
|
35418
|
+
JOIN LATERAL unnest(pg_blocking_pids(bl.pid)) AS bpid(pid) ON TRUE
|
|
35419
|
+
JOIN pg_catalog.pg_stat_activity blocking ON blocking.pid = bpid.pid
|
|
35420
|
+
WHERE NOT bl.granted
|
|
35421
|
+
ORDER BY blocked.query_start NULLS LAST
|
|
35422
|
+
LIMIT $1`,
|
|
35423
|
+
[limit]
|
|
35424
|
+
);
|
|
35425
|
+
}
|
|
35426
|
+
},
|
|
35427
|
+
{
|
|
35428
|
+
name: "pg_list_roles",
|
|
35429
|
+
description: "List database roles (users and groups) with their login/superuser/createdb/createrole attributes and inherited role memberships. Use this to answer 'who has access to this database?' without needing to read `pg_authid` directly.",
|
|
35430
|
+
annotations: {
|
|
35431
|
+
title: "List roles",
|
|
35432
|
+
readOnlyHint: true,
|
|
35433
|
+
destructiveHint: false,
|
|
35434
|
+
idempotentHint: true,
|
|
35435
|
+
openWorldHint: true
|
|
35436
|
+
},
|
|
35437
|
+
inputSchema: external_exports3.object({
|
|
35438
|
+
includeSystem: external_exports3.boolean().default(false).describe("If true, include built-in `pg_*` roles (pg_read_all_data, pg_monitor, etc.).")
|
|
35439
|
+
}),
|
|
35440
|
+
handler: async (input) => {
|
|
35441
|
+
const { includeSystem } = input;
|
|
35442
|
+
const filter = includeSystem ? "" : "WHERE r.rolname NOT LIKE 'pg\\_%' ESCAPE '\\\\'";
|
|
35443
|
+
return runInternal(
|
|
35444
|
+
`SELECT
|
|
35445
|
+
r.rolname AS name,
|
|
35446
|
+
r.rolcanlogin AS can_login,
|
|
35447
|
+
r.rolsuper AS superuser,
|
|
35448
|
+
r.rolcreatedb AS createdb,
|
|
35449
|
+
r.rolcreaterole AS createrole,
|
|
35450
|
+
r.rolreplication AS replication,
|
|
35451
|
+
r.rolbypassrls AS bypass_rls,
|
|
35452
|
+
COALESCE(
|
|
35453
|
+
(SELECT array_agg(g.rolname ORDER BY g.rolname)
|
|
35454
|
+
FROM pg_catalog.pg_auth_members m
|
|
35455
|
+
JOIN pg_catalog.pg_roles g ON g.oid = m.roleid
|
|
35456
|
+
WHERE m.member = r.oid),
|
|
35457
|
+
ARRAY[]::name[]
|
|
35458
|
+
) AS member_of
|
|
35459
|
+
FROM pg_catalog.pg_roles r
|
|
35460
|
+
${filter}
|
|
35461
|
+
ORDER BY r.rolname`
|
|
35462
|
+
);
|
|
35463
|
+
}
|
|
35464
|
+
},
|
|
35465
|
+
{
|
|
35466
|
+
name: "pg_table_privileges",
|
|
35467
|
+
description: "Show which roles have which privileges (SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER) on a table or on all tables in a schema. Use this to answer 'who can write to this table?' or to audit schema-wide access before a migration.",
|
|
35468
|
+
annotations: {
|
|
35469
|
+
title: "Show table privileges",
|
|
35470
|
+
readOnlyHint: true,
|
|
35471
|
+
destructiveHint: false,
|
|
35472
|
+
idempotentHint: true,
|
|
35473
|
+
openWorldHint: true
|
|
35474
|
+
},
|
|
35475
|
+
inputSchema: external_exports3.object({
|
|
35476
|
+
schema: external_exports3.string().min(1).max(63).default("public").describe("Schema name (defaults to 'public')."),
|
|
35477
|
+
table: external_exports3.string().min(1).max(63).optional().describe("Table name. Omit to list privileges for all tables in the schema.")
|
|
35478
|
+
}),
|
|
35479
|
+
handler: async (input) => {
|
|
35480
|
+
const { schema, table } = input;
|
|
35481
|
+
const tableFilter = table ? "AND table_name = $2" : "";
|
|
35482
|
+
const params = [schema];
|
|
35483
|
+
if (table) params.push(table);
|
|
35484
|
+
return runInternal(
|
|
35485
|
+
`SELECT
|
|
35486
|
+
table_name AS "table",
|
|
35487
|
+
grantee,
|
|
35488
|
+
privilege_type,
|
|
35489
|
+
is_grantable::boolean AS is_grantable
|
|
35490
|
+
FROM information_schema.table_privileges
|
|
35491
|
+
WHERE table_schema = $1
|
|
35492
|
+
${tableFilter}
|
|
35493
|
+
ORDER BY table_name, grantee, privilege_type`,
|
|
35494
|
+
params
|
|
35495
|
+
);
|
|
35496
|
+
}
|
|
35497
|
+
},
|
|
35498
|
+
{
|
|
35499
|
+
name: "pg_kill",
|
|
35500
|
+
description: "Cancel a running query (SIGINT-equivalent) or terminate a backend connection (SIGTERM-equivalent) by PID. Find the PID via `pg_health` active_queries or `pg_inspect_locks`. Requires ALLOW_WRITES=1 since this changes database session state. The role in DATABASE_URL must have permission \u2014 cancelling another user's query needs the `pg_signal_backend` role or superuser. Cancel is graceful; terminate is forceful.",
|
|
35501
|
+
annotations: {
|
|
35502
|
+
title: "Cancel or terminate a backend",
|
|
35503
|
+
readOnlyHint: false,
|
|
35504
|
+
destructiveHint: true,
|
|
35505
|
+
idempotentHint: false,
|
|
35506
|
+
openWorldHint: true
|
|
35507
|
+
},
|
|
35508
|
+
inputSchema: external_exports3.object({
|
|
35509
|
+
pid: external_exports3.number().int().min(1).describe("Backend PID to signal."),
|
|
35510
|
+
mode: external_exports3.enum(["cancel", "terminate"]).default("cancel").describe("`cancel` aborts the current query; `terminate` closes the connection entirely.")
|
|
35511
|
+
}),
|
|
35512
|
+
handler: async (input) => {
|
|
35513
|
+
const { pid, mode } = input;
|
|
35514
|
+
if (!isWritesAllowed()) {
|
|
35515
|
+
return {
|
|
35516
|
+
ok: false,
|
|
35517
|
+
error: "pg_kill requires ALLOW_WRITES=1 because cancelling or terminating a backend changes session state. Set ALLOW_WRITES=1 in the MCP server env."
|
|
35518
|
+
};
|
|
35519
|
+
}
|
|
35520
|
+
const fn = mode === "terminate" ? "pg_terminate_backend" : "pg_cancel_backend";
|
|
35521
|
+
const result = await runInternal(`SELECT ${fn}($1) AS signaled`, [pid]);
|
|
35522
|
+
if (!result.ok) return result;
|
|
35523
|
+
const signaled = result.data?.[0]?.signaled === true;
|
|
35524
|
+
return {
|
|
35525
|
+
ok: true,
|
|
35526
|
+
data: {
|
|
35527
|
+
pid,
|
|
35528
|
+
mode,
|
|
35529
|
+
signaled,
|
|
35530
|
+
note: signaled ? `Sent ${mode === "terminate" ? "SIGTERM" : "SIGINT"} to backend ${pid}.` : `Signal returned false \u2014 PID ${pid} may not exist, may already be gone, or the current role lacks permission.`
|
|
35531
|
+
}
|
|
35532
|
+
};
|
|
35533
|
+
}
|
|
35534
|
+
},
|
|
35535
|
+
{
|
|
35536
|
+
name: "pg_replication_status",
|
|
35537
|
+
description: "Replication overview: configured replication slots, connected replicas (from `pg_stat_replication`), and current WAL position. Use on primary to spot lagging or disconnected replicas, on replicas to see upstream status. Returns empty arrays on a standalone (non-replicated) database rather than erroring.",
|
|
35538
|
+
annotations: {
|
|
35539
|
+
title: "Replication status",
|
|
35540
|
+
readOnlyHint: true,
|
|
35541
|
+
destructiveHint: false,
|
|
35542
|
+
idempotentHint: true,
|
|
35543
|
+
openWorldHint: true
|
|
35544
|
+
},
|
|
35545
|
+
inputSchema: external_exports3.object({}),
|
|
35546
|
+
handler: async () => {
|
|
35547
|
+
const [slotsRes, replicasRes, walRes] = await Promise.all([
|
|
35548
|
+
runInternal(
|
|
35549
|
+
`SELECT
|
|
35550
|
+
slot_name, slot_type, active,
|
|
35551
|
+
restart_lsn::text AS restart_lsn,
|
|
35552
|
+
confirmed_flush_lsn::text AS confirmed_flush_lsn,
|
|
35553
|
+
wal_status, database, plugin
|
|
35554
|
+
FROM pg_catalog.pg_replication_slots
|
|
35555
|
+
ORDER BY slot_name`
|
|
35556
|
+
),
|
|
35557
|
+
runInternal(
|
|
35558
|
+
`SELECT
|
|
35559
|
+
application_name,
|
|
35560
|
+
client_addr::text AS client_addr,
|
|
35561
|
+
state,
|
|
35562
|
+
sync_state,
|
|
35563
|
+
EXTRACT(EPOCH FROM write_lag)::numeric(10, 2)::float8 AS write_lag_seconds,
|
|
35564
|
+
EXTRACT(EPOCH FROM flush_lag)::numeric(10, 2)::float8 AS flush_lag_seconds,
|
|
35565
|
+
EXTRACT(EPOCH FROM replay_lag)::numeric(10, 2)::float8 AS replay_lag_seconds
|
|
35566
|
+
FROM pg_catalog.pg_stat_replication
|
|
35567
|
+
ORDER BY application_name`
|
|
35568
|
+
),
|
|
35569
|
+
runInternal(
|
|
35570
|
+
`SELECT
|
|
35571
|
+
pg_is_in_recovery() AS is_in_recovery,
|
|
35572
|
+
CASE
|
|
35573
|
+
WHEN pg_is_in_recovery() THEN pg_last_wal_receive_lsn()::text
|
|
35574
|
+
ELSE pg_current_wal_lsn()::text
|
|
35575
|
+
END AS wal_position`
|
|
35576
|
+
)
|
|
35577
|
+
]);
|
|
35578
|
+
if (!slotsRes.ok) return slotsRes;
|
|
35579
|
+
if (!replicasRes.ok) return replicasRes;
|
|
35580
|
+
if (!walRes.ok) return walRes;
|
|
35581
|
+
return {
|
|
35582
|
+
ok: true,
|
|
35583
|
+
data: {
|
|
35584
|
+
is_replica: walRes.data?.[0]?.is_in_recovery ?? false,
|
|
35585
|
+
wal_position: walRes.data?.[0]?.wal_position ?? null,
|
|
35586
|
+
slots: slotsRes.data ?? [],
|
|
35587
|
+
replicas: replicasRes.data ?? []
|
|
35588
|
+
}
|
|
35589
|
+
};
|
|
35590
|
+
}
|
|
35591
|
+
},
|
|
35592
|
+
{
|
|
35593
|
+
name: "pg_table_bloat",
|
|
35594
|
+
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. Cheap \u2014 uses `pg_stat_user_tables`, no extensions required.",
|
|
35595
|
+
annotations: {
|
|
35596
|
+
title: "Estimate table bloat",
|
|
35597
|
+
readOnlyHint: true,
|
|
35598
|
+
destructiveHint: false,
|
|
35599
|
+
idempotentHint: true,
|
|
35600
|
+
openWorldHint: true
|
|
35601
|
+
},
|
|
35602
|
+
inputSchema: external_exports3.object({
|
|
35603
|
+
schema: external_exports3.string().min(1).max(63).optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
35604
|
+
minDeadRatio: external_exports3.number().min(0).max(1).default(0.1).describe("Minimum dead/live ratio to include (default 0.1 = 10%)."),
|
|
35605
|
+
limit: external_exports3.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
|
|
35606
|
+
}),
|
|
35607
|
+
handler: async (input) => {
|
|
35608
|
+
const { schema, minDeadRatio, limit } = input;
|
|
35609
|
+
const schemaFilter = schema ? "AND schemaname = $3" : "AND schemaname NOT IN ('pg_catalog', 'information_schema') AND schemaname NOT LIKE 'pg_%'";
|
|
35610
|
+
const params = [minDeadRatio, limit];
|
|
35611
|
+
if (schema) params.push(schema);
|
|
35612
|
+
return runInternal(
|
|
35613
|
+
`SELECT
|
|
35614
|
+
schemaname AS schema,
|
|
35615
|
+
relname AS "table",
|
|
35616
|
+
n_live_tup::text AS live_tuples,
|
|
35617
|
+
n_dead_tup::text AS dead_tuples,
|
|
35618
|
+
CASE
|
|
35619
|
+
WHEN n_live_tup = 0 THEN 0
|
|
35620
|
+
ELSE (n_dead_tup::float8 / GREATEST(n_live_tup, 1))::numeric(6, 3)::float8
|
|
35621
|
+
END AS dead_ratio,
|
|
35622
|
+
pg_size_pretty(pg_total_relation_size(relid)) AS size_pretty,
|
|
35623
|
+
pg_total_relation_size(relid)::text AS size_bytes,
|
|
35624
|
+
last_vacuum::text AS last_vacuum,
|
|
35625
|
+
last_autovacuum::text AS last_autovacuum,
|
|
35626
|
+
last_analyze::text AS last_analyze
|
|
35627
|
+
FROM pg_catalog.pg_stat_user_tables
|
|
35628
|
+
WHERE (n_dead_tup::float8 / GREATEST(n_live_tup, 1)) >= $1
|
|
35629
|
+
${schemaFilter}
|
|
35630
|
+
ORDER BY n_dead_tup DESC
|
|
35631
|
+
LIMIT $2`,
|
|
35632
|
+
params
|
|
35633
|
+
);
|
|
35634
|
+
}
|
|
35635
|
+
}
|
|
35636
|
+
];
|
|
35637
|
+
|
|
35367
35638
|
// src/tools/explain.ts
|
|
35639
|
+
var paramValue = external_exports3.lazy(
|
|
35640
|
+
() => 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)])
|
|
35641
|
+
);
|
|
35368
35642
|
var explainTools = [
|
|
35369
35643
|
{
|
|
35370
35644
|
name: "pg_explain",
|
|
35371
|
-
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`.",
|
|
35645
|
+
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).",
|
|
35372
35646
|
annotations: {
|
|
35373
35647
|
title: "Explain query plan",
|
|
35374
35648
|
readOnlyHint: false,
|
|
@@ -35377,13 +35651,19 @@ var explainTools = [
|
|
|
35377
35651
|
openWorldHint: true
|
|
35378
35652
|
},
|
|
35379
35653
|
inputSchema: external_exports3.object({
|
|
35380
|
-
sql: external_exports3.string().min(1).describe("The SQL statement to explain."),
|
|
35654
|
+
sql: external_exports3.string().min(1).max(1e6).describe("The SQL statement to explain. Do NOT prefix with EXPLAIN."),
|
|
35381
35655
|
analyze: external_exports3.boolean().default(false).describe("Run EXPLAIN ANALYZE (actually executes the query)."),
|
|
35382
35656
|
format: external_exports3.enum(["text", "json"]).default("text").describe("Output format."),
|
|
35383
|
-
params: external_exports3.array(
|
|
35657
|
+
params: external_exports3.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
|
|
35384
35658
|
}),
|
|
35385
35659
|
handler: async (input) => {
|
|
35386
35660
|
const { sql, analyze, format, params } = input;
|
|
35661
|
+
if (/^\s*EXPLAIN\b/i.test(sql)) {
|
|
35662
|
+
return {
|
|
35663
|
+
ok: false,
|
|
35664
|
+
error: "The `sql` parameter should be the query to explain, not an EXPLAIN statement. Use the `analyze` and `format` parameters on this tool instead of prefixing the SQL."
|
|
35665
|
+
};
|
|
35666
|
+
}
|
|
35387
35667
|
const flags = [];
|
|
35388
35668
|
if (analyze) flags.push("ANALYZE");
|
|
35389
35669
|
if (format === "json") flags.push("FORMAT JSON");
|
|
@@ -35414,8 +35694,11 @@ var healthTools = [
|
|
|
35414
35694
|
idempotentHint: true,
|
|
35415
35695
|
openWorldHint: true
|
|
35416
35696
|
},
|
|
35417
|
-
inputSchema: external_exports3.object({
|
|
35418
|
-
|
|
35697
|
+
inputSchema: external_exports3.object({
|
|
35698
|
+
activeQueryLimit: external_exports3.number().int().min(1).max(100).default(10).describe("Max active queries to return (default 10, max 100).")
|
|
35699
|
+
}),
|
|
35700
|
+
handler: async (input) => {
|
|
35701
|
+
const { activeQueryLimit } = input;
|
|
35419
35702
|
const [versionRes, sizeRes, connsRes, activeRes, tableCountRes] = await Promise.all([
|
|
35420
35703
|
runInternal(`SELECT version() AS version`),
|
|
35421
35704
|
runInternal(
|
|
@@ -35446,7 +35729,8 @@ var healthTools = [
|
|
|
35446
35729
|
AND state <> 'idle'
|
|
35447
35730
|
AND pid <> pg_backend_pid()
|
|
35448
35731
|
ORDER BY query_start ASC NULLS LAST
|
|
35449
|
-
LIMIT
|
|
35732
|
+
LIMIT $1`,
|
|
35733
|
+
[activeQueryLimit]
|
|
35450
35734
|
),
|
|
35451
35735
|
runInternal(
|
|
35452
35736
|
`SELECT count(*)::text AS count
|
|
@@ -35474,10 +35758,13 @@ var healthTools = [
|
|
|
35474
35758
|
];
|
|
35475
35759
|
|
|
35476
35760
|
// src/tools/query.ts
|
|
35761
|
+
var paramValue2 = external_exports3.lazy(
|
|
35762
|
+
() => 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)])
|
|
35763
|
+
);
|
|
35477
35764
|
var queryTools = [
|
|
35478
35765
|
{
|
|
35479
35766
|
name: "pg_query",
|
|
35480
|
-
description: "Run a SQL query against the configured PostgreSQL database. Read-only by default (query runs in a READ ONLY transaction). Set ALLOW_WRITES=1 in the MCP server env to enable DML/DDL. Use `params` for parameterized queries to avoid SQL injection. Large result sets are truncated to POSTGRES_MAX_ROWS (default 1000) with a `truncated: true` flag.",
|
|
35767
|
+
description: "Run a SQL query against the configured PostgreSQL database. Read-only by default (query runs in a READ ONLY transaction). Set ALLOW_WRITES=1 in the MCP server env to enable DML/DDL. 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.",
|
|
35481
35768
|
annotations: {
|
|
35482
35769
|
title: "Run SQL query",
|
|
35483
35770
|
readOnlyHint: false,
|
|
@@ -35487,8 +35774,8 @@ var queryTools = [
|
|
|
35487
35774
|
openWorldHint: true
|
|
35488
35775
|
},
|
|
35489
35776
|
inputSchema: external_exports3.object({
|
|
35490
|
-
sql: external_exports3.string().min(1).describe("The SQL statement to execute."),
|
|
35491
|
-
params: external_exports3.array(
|
|
35777
|
+
sql: external_exports3.string().min(1).max(1e6).describe("The SQL statement to execute. Hard cap of 1 MB."),
|
|
35778
|
+
params: external_exports3.array(paramValue2).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
|
|
35492
35779
|
}),
|
|
35493
35780
|
handler: async (input) => {
|
|
35494
35781
|
const { sql, params } = input;
|
|
@@ -35501,12 +35788,7 @@ var queryTools = [
|
|
|
35501
35788
|
];
|
|
35502
35789
|
|
|
35503
35790
|
// src/tools/schemas.ts
|
|
35504
|
-
var
|
|
35505
|
-
function validateIdent(value, field) {
|
|
35506
|
-
if (!IDENT_RE.test(value)) {
|
|
35507
|
-
throw new Error(`Invalid ${field}: ${JSON.stringify(value)}. Must match ${IDENT_RE}.`);
|
|
35508
|
-
}
|
|
35509
|
-
}
|
|
35791
|
+
var identSchema = external_exports3.string().min(1).max(63);
|
|
35510
35792
|
var schemaTools = [
|
|
35511
35793
|
{
|
|
35512
35794
|
name: "pg_list_schemas",
|
|
@@ -35534,7 +35816,7 @@ var schemaTools = [
|
|
|
35534
35816
|
},
|
|
35535
35817
|
{
|
|
35536
35818
|
name: "pg_list_tables",
|
|
35537
|
-
description: "List tables (and optionally views) in a schema. Returns name, type (table/view/materialized view/foreign), and estimated row count.",
|
|
35819
|
+
description: "List tables (and optionally views) in a schema. Returns name, type (table/view/materialized view/foreign), and estimated row count (from `reltuples`; approximate \u2014 0 until ANALYZE runs). Paginate via `limit`/`offset` on very large schemas.",
|
|
35538
35820
|
annotations: {
|
|
35539
35821
|
title: "List tables in a schema",
|
|
35540
35822
|
readOnlyHint: true,
|
|
@@ -35543,12 +35825,13 @@ var schemaTools = [
|
|
|
35543
35825
|
openWorldHint: true
|
|
35544
35826
|
},
|
|
35545
35827
|
inputSchema: external_exports3.object({
|
|
35546
|
-
schema:
|
|
35547
|
-
includeViews: external_exports3.boolean().default(false).describe("If true, include views and materialized views.")
|
|
35828
|
+
schema: identSchema.default("public").describe("Schema name (defaults to 'public')."),
|
|
35829
|
+
includeViews: external_exports3.boolean().default(false).describe("If true, include views and materialized views."),
|
|
35830
|
+
limit: external_exports3.number().int().min(1).max(1e4).default(500).describe("Max rows to return (default 500, max 10000)."),
|
|
35831
|
+
offset: external_exports3.number().int().min(0).default(0).describe("Rows to skip for pagination (default 0).")
|
|
35548
35832
|
}),
|
|
35549
35833
|
handler: async (input) => {
|
|
35550
|
-
const { schema, includeViews } = input;
|
|
35551
|
-
validateIdent(schema, "schema");
|
|
35834
|
+
const { schema, includeViews, limit, offset } = input;
|
|
35552
35835
|
const kinds = includeViews ? "('r', 'v', 'm', 'f', 'p')" : "('r', 'f', 'p')";
|
|
35553
35836
|
return runInternal(
|
|
35554
35837
|
`SELECT
|
|
@@ -35566,8 +35849,9 @@ var schemaTools = [
|
|
|
35566
35849
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
35567
35850
|
WHERE n.nspname = $1
|
|
35568
35851
|
AND c.relkind IN ${kinds}
|
|
35569
|
-
ORDER BY c.relname
|
|
35570
|
-
|
|
35852
|
+
ORDER BY c.relname
|
|
35853
|
+
LIMIT $2 OFFSET $3`,
|
|
35854
|
+
[schema, limit, offset]
|
|
35571
35855
|
);
|
|
35572
35856
|
}
|
|
35573
35857
|
},
|
|
@@ -35582,13 +35866,11 @@ var schemaTools = [
|
|
|
35582
35866
|
openWorldHint: true
|
|
35583
35867
|
},
|
|
35584
35868
|
inputSchema: external_exports3.object({
|
|
35585
|
-
schema:
|
|
35586
|
-
table:
|
|
35869
|
+
schema: identSchema.default("public").describe("Schema name (defaults to 'public')."),
|
|
35870
|
+
table: identSchema.describe("Table name.")
|
|
35587
35871
|
}),
|
|
35588
35872
|
handler: async (input) => {
|
|
35589
35873
|
const { schema, table } = input;
|
|
35590
|
-
validateIdent(schema, "schema");
|
|
35591
|
-
validateIdent(table, "table");
|
|
35592
35874
|
const columnsQuery = `
|
|
35593
35875
|
SELECT
|
|
35594
35876
|
a.attname AS name,
|
|
@@ -35663,6 +35945,10 @@ var schemaTools = [
|
|
|
35663
35945
|
if (!cols.data || cols.data.length === 0) {
|
|
35664
35946
|
return { ok: false, error: `Table "${schema}"."${table}" not found.` };
|
|
35665
35947
|
}
|
|
35948
|
+
const warnings = [];
|
|
35949
|
+
if (!pk.ok) warnings.push(`primary_key fetch failed: ${pk.error}`);
|
|
35950
|
+
if (!fks.ok) warnings.push(`foreign_keys fetch failed: ${fks.error}`);
|
|
35951
|
+
if (!idxs.ok) warnings.push(`indexes fetch failed: ${idxs.error}`);
|
|
35666
35952
|
return {
|
|
35667
35953
|
ok: true,
|
|
35668
35954
|
data: {
|
|
@@ -35671,21 +35957,314 @@ var schemaTools = [
|
|
|
35671
35957
|
columns: cols.data,
|
|
35672
35958
|
primary_key: pk.ok ? (pk.data ?? []).map((r) => r.column_name) : [],
|
|
35673
35959
|
foreign_keys: fks.ok ? fks.data : [],
|
|
35674
|
-
indexes: idxs.ok ? idxs.data : []
|
|
35960
|
+
indexes: idxs.ok ? idxs.data : [],
|
|
35961
|
+
...warnings.length > 0 ? { _warnings: warnings } : {}
|
|
35675
35962
|
}
|
|
35676
35963
|
};
|
|
35677
35964
|
}
|
|
35965
|
+
},
|
|
35966
|
+
{
|
|
35967
|
+
name: "pg_list_views",
|
|
35968
|
+
description: "List views and materialized views in a schema with their SQL definitions. Use this over `pg_list_tables` with `includeViews: true` when you want the view body, not just names.",
|
|
35969
|
+
annotations: {
|
|
35970
|
+
title: "List views with definitions",
|
|
35971
|
+
readOnlyHint: true,
|
|
35972
|
+
destructiveHint: false,
|
|
35973
|
+
idempotentHint: true,
|
|
35974
|
+
openWorldHint: true
|
|
35975
|
+
},
|
|
35976
|
+
inputSchema: external_exports3.object({
|
|
35977
|
+
schema: identSchema.default("public").describe("Schema name (defaults to 'public')."),
|
|
35978
|
+
includeMaterialized: external_exports3.boolean().default(true).describe("If true, include materialized views.")
|
|
35979
|
+
}),
|
|
35980
|
+
handler: async (input) => {
|
|
35981
|
+
const { schema, includeMaterialized } = input;
|
|
35982
|
+
const kinds = includeMaterialized ? "('v', 'm')" : "('v')";
|
|
35983
|
+
return runInternal(
|
|
35984
|
+
`SELECT
|
|
35985
|
+
c.relname AS name,
|
|
35986
|
+
CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized_view' END AS type,
|
|
35987
|
+
pg_catalog.pg_get_viewdef(c.oid, true) AS definition
|
|
35988
|
+
FROM pg_catalog.pg_class c
|
|
35989
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
35990
|
+
WHERE n.nspname = $1
|
|
35991
|
+
AND c.relkind IN ${kinds}
|
|
35992
|
+
ORDER BY c.relname`,
|
|
35993
|
+
[schema]
|
|
35994
|
+
);
|
|
35995
|
+
}
|
|
35996
|
+
},
|
|
35997
|
+
{
|
|
35998
|
+
name: "pg_list_functions",
|
|
35999
|
+
description: "List functions, procedures, and aggregates in a schema. Returns name, arguments, return type, kind (function/procedure/aggregate/window), and implementation language.",
|
|
36000
|
+
annotations: {
|
|
36001
|
+
title: "List functions and procedures",
|
|
36002
|
+
readOnlyHint: true,
|
|
36003
|
+
destructiveHint: false,
|
|
36004
|
+
idempotentHint: true,
|
|
36005
|
+
openWorldHint: true
|
|
36006
|
+
},
|
|
36007
|
+
inputSchema: external_exports3.object({
|
|
36008
|
+
schema: identSchema.default("public").describe("Schema name (defaults to 'public').")
|
|
36009
|
+
}),
|
|
36010
|
+
handler: async (input) => {
|
|
36011
|
+
const { schema } = input;
|
|
36012
|
+
return runInternal(
|
|
36013
|
+
`SELECT
|
|
36014
|
+
p.proname AS name,
|
|
36015
|
+
pg_catalog.pg_get_function_arguments(p.oid) AS arguments,
|
|
36016
|
+
pg_catalog.pg_get_function_result(p.oid) AS return_type,
|
|
36017
|
+
CASE p.prokind
|
|
36018
|
+
WHEN 'f' THEN 'function'
|
|
36019
|
+
WHEN 'p' THEN 'procedure'
|
|
36020
|
+
WHEN 'a' THEN 'aggregate'
|
|
36021
|
+
WHEN 'w' THEN 'window'
|
|
36022
|
+
ELSE p.prokind::text
|
|
36023
|
+
END AS kind,
|
|
36024
|
+
l.lanname AS language
|
|
36025
|
+
FROM pg_catalog.pg_proc p
|
|
36026
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
36027
|
+
JOIN pg_catalog.pg_language l ON l.oid = p.prolang
|
|
36028
|
+
WHERE n.nspname = $1
|
|
36029
|
+
ORDER BY p.proname, p.oid`,
|
|
36030
|
+
[schema]
|
|
36031
|
+
);
|
|
36032
|
+
}
|
|
36033
|
+
},
|
|
36034
|
+
{
|
|
36035
|
+
name: "pg_list_extensions",
|
|
36036
|
+
description: "List installed PostgreSQL extensions. Returns name, version, schema, and description. Useful to check for pgvector, postgis, pg_stat_statements, uuid-ossp, etc. before writing queries that rely on them.",
|
|
36037
|
+
annotations: {
|
|
36038
|
+
title: "List installed extensions",
|
|
36039
|
+
readOnlyHint: true,
|
|
36040
|
+
destructiveHint: false,
|
|
36041
|
+
idempotentHint: true,
|
|
36042
|
+
openWorldHint: true
|
|
36043
|
+
},
|
|
36044
|
+
inputSchema: external_exports3.object({}),
|
|
36045
|
+
handler: async () => {
|
|
36046
|
+
return runInternal(
|
|
36047
|
+
`SELECT
|
|
36048
|
+
e.extname AS name,
|
|
36049
|
+
e.extversion AS version,
|
|
36050
|
+
n.nspname AS schema,
|
|
36051
|
+
d.description
|
|
36052
|
+
FROM pg_catalog.pg_extension e
|
|
36053
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace
|
|
36054
|
+
LEFT JOIN pg_catalog.pg_description d ON d.objoid = e.oid AND d.classoid = 'pg_extension'::regclass
|
|
36055
|
+
ORDER BY e.extname`
|
|
36056
|
+
);
|
|
36057
|
+
}
|
|
36058
|
+
},
|
|
36059
|
+
{
|
|
36060
|
+
name: "pg_search_columns",
|
|
36061
|
+
description: "Search for columns by name across all user schemas. Supports SQL LIKE patterns (`%` matches any substring, `_` matches one character). Case-insensitive. Use this instead of iterating `pg_describe_table` when the user asks 'which tables have X'.",
|
|
36062
|
+
annotations: {
|
|
36063
|
+
title: "Search columns by name",
|
|
36064
|
+
readOnlyHint: true,
|
|
36065
|
+
destructiveHint: false,
|
|
36066
|
+
idempotentHint: true,
|
|
36067
|
+
openWorldHint: true
|
|
36068
|
+
},
|
|
36069
|
+
inputSchema: external_exports3.object({
|
|
36070
|
+
pattern: external_exports3.string().min(1).describe("LIKE pattern. Use '%' for wildcard: 'user_id', '%email%', 'created_%'."),
|
|
36071
|
+
schema: identSchema.optional().describe("Limit to this schema. If omitted, searches all user schemas."),
|
|
36072
|
+
limit: external_exports3.number().int().min(1).max(1e3).default(100).describe("Max rows to return (default 100).")
|
|
36073
|
+
}),
|
|
36074
|
+
handler: async (input) => {
|
|
36075
|
+
const { pattern, schema, limit } = input;
|
|
36076
|
+
const schemaFilter = schema ? "AND n.nspname = $3" : "AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'";
|
|
36077
|
+
const params = [pattern, limit];
|
|
36078
|
+
if (schema) params.push(schema);
|
|
36079
|
+
return runInternal(
|
|
36080
|
+
`SELECT
|
|
36081
|
+
n.nspname AS schema,
|
|
36082
|
+
c.relname AS "table",
|
|
36083
|
+
a.attname AS "column",
|
|
36084
|
+
pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
|
|
36085
|
+
NOT a.attnotnull AS nullable
|
|
36086
|
+
FROM pg_catalog.pg_attribute a
|
|
36087
|
+
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
36088
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
36089
|
+
WHERE a.attname ILIKE $1
|
|
36090
|
+
AND a.attnum > 0
|
|
36091
|
+
AND NOT a.attisdropped
|
|
36092
|
+
AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
|
36093
|
+
${schemaFilter}
|
|
36094
|
+
ORDER BY n.nspname, c.relname, a.attnum
|
|
36095
|
+
LIMIT $2`,
|
|
36096
|
+
params
|
|
36097
|
+
);
|
|
36098
|
+
}
|
|
36099
|
+
}
|
|
36100
|
+
];
|
|
36101
|
+
|
|
36102
|
+
// src/tools/stats.ts
|
|
36103
|
+
var identSchema2 = external_exports3.string().min(1).max(63);
|
|
36104
|
+
var statsTools = [
|
|
36105
|
+
{
|
|
36106
|
+
name: "pg_top_queries",
|
|
36107
|
+
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.",
|
|
36108
|
+
annotations: {
|
|
36109
|
+
title: "Top queries by execution time",
|
|
36110
|
+
readOnlyHint: true,
|
|
36111
|
+
destructiveHint: false,
|
|
36112
|
+
idempotentHint: true,
|
|
36113
|
+
openWorldHint: true
|
|
36114
|
+
},
|
|
36115
|
+
inputSchema: external_exports3.object({
|
|
36116
|
+
orderBy: external_exports3.enum(["total_time", "mean_time", "calls"]).default("total_time").describe("Ranking: total_time (cumulative impact), mean_time (worst per-call), or calls (hottest)."),
|
|
36117
|
+
limit: external_exports3.number().int().min(1).max(100).default(20).describe("Number of rows to return (default 20).")
|
|
36118
|
+
}),
|
|
36119
|
+
handler: async (input) => {
|
|
36120
|
+
const { orderBy, limit } = input;
|
|
36121
|
+
const check2 = await runInternal(
|
|
36122
|
+
`SELECT EXISTS (
|
|
36123
|
+
SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'
|
|
36124
|
+
) AS installed`
|
|
36125
|
+
);
|
|
36126
|
+
if (!check2.ok) return check2;
|
|
36127
|
+
if (!check2.data?.[0]?.installed) {
|
|
36128
|
+
return {
|
|
36129
|
+
ok: false,
|
|
36130
|
+
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."
|
|
36131
|
+
};
|
|
36132
|
+
}
|
|
36133
|
+
const versionRes = await runInternal(
|
|
36134
|
+
`SELECT extversion AS version FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'`
|
|
36135
|
+
);
|
|
36136
|
+
const extVersion = versionRes.ok ? versionRes.data?.[0]?.version ?? "0" : "0";
|
|
36137
|
+
const useExecSuffix = compareVersions(extVersion, "1.8") >= 0;
|
|
36138
|
+
const totalCol = useExecSuffix ? "total_exec_time" : "total_time";
|
|
36139
|
+
const meanCol = useExecSuffix ? "mean_exec_time" : "mean_time";
|
|
36140
|
+
const minCol = useExecSuffix ? "min_exec_time" : "min_time";
|
|
36141
|
+
const maxCol = useExecSuffix ? "max_exec_time" : "max_time";
|
|
36142
|
+
const orderCol = orderBy === "total_time" ? totalCol : orderBy === "mean_time" ? meanCol : "calls";
|
|
36143
|
+
return runInternal(
|
|
36144
|
+
`SELECT
|
|
36145
|
+
query,
|
|
36146
|
+
calls::text AS calls,
|
|
36147
|
+
${totalCol}::numeric(18, 2)::float8 AS total_time_ms,
|
|
36148
|
+
${meanCol}::numeric(18, 2)::float8 AS mean_time_ms,
|
|
36149
|
+
${minCol}::numeric(18, 2)::float8 AS min_time_ms,
|
|
36150
|
+
${maxCol}::numeric(18, 2)::float8 AS max_time_ms,
|
|
36151
|
+
rows::text AS rows,
|
|
36152
|
+
CASE
|
|
36153
|
+
WHEN (shared_blks_hit + shared_blks_read) > 0
|
|
36154
|
+
THEN (shared_blks_hit::float8 / (shared_blks_hit + shared_blks_read) * 100)::numeric(5, 2)::float8
|
|
36155
|
+
ELSE NULL
|
|
36156
|
+
END AS hit_percent
|
|
36157
|
+
FROM pg_stat_statements
|
|
36158
|
+
ORDER BY ${orderCol} DESC NULLS LAST
|
|
36159
|
+
LIMIT $1`,
|
|
36160
|
+
[limit]
|
|
36161
|
+
);
|
|
36162
|
+
}
|
|
36163
|
+
},
|
|
36164
|
+
{
|
|
36165
|
+
name: "pg_seq_scan_tables",
|
|
36166
|
+
description: "Tables with high sequential-scan counts relative to index scans \u2014 the first place to look for missing-index candidates. Returns seq_scans, idx_scans, live tuples, and the ratio. A high ratio on a large table usually means a query is reading the whole table where an index would suffice. Pair with `pg_top_queries` to find which query is doing it.",
|
|
36167
|
+
annotations: {
|
|
36168
|
+
title: "Find tables with heavy sequential scans",
|
|
36169
|
+
readOnlyHint: true,
|
|
36170
|
+
destructiveHint: false,
|
|
36171
|
+
idempotentHint: true,
|
|
36172
|
+
openWorldHint: true
|
|
36173
|
+
},
|
|
36174
|
+
inputSchema: external_exports3.object({
|
|
36175
|
+
schema: identSchema2.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
36176
|
+
minSize: external_exports3.number().int().min(0).default(1e3).describe("Minimum live tuple count to include (default 1000, filters out tiny/empty tables)."),
|
|
36177
|
+
limit: external_exports3.number().int().min(1).max(100).default(20).describe("Max rows to return (default 20).")
|
|
36178
|
+
}),
|
|
36179
|
+
handler: async (input) => {
|
|
36180
|
+
const { schema, minSize, limit } = input;
|
|
36181
|
+
const schemaFilter = schema ? "AND schemaname = $3" : "AND schemaname NOT IN ('pg_catalog', 'information_schema') AND schemaname NOT LIKE 'pg_%'";
|
|
36182
|
+
const params = [minSize, limit];
|
|
36183
|
+
if (schema) params.push(schema);
|
|
36184
|
+
return runInternal(
|
|
36185
|
+
`SELECT
|
|
36186
|
+
schemaname AS schema,
|
|
36187
|
+
relname AS "table",
|
|
36188
|
+
seq_scan::text AS seq_scans,
|
|
36189
|
+
COALESCE(idx_scan, 0)::text AS idx_scans,
|
|
36190
|
+
n_live_tup::text AS live_tuples,
|
|
36191
|
+
seq_tup_read::text AS seq_tup_read,
|
|
36192
|
+
CASE
|
|
36193
|
+
WHEN COALESCE(idx_scan, 0) = 0 AND seq_scan > 0 THEN NULL
|
|
36194
|
+
WHEN COALESCE(idx_scan, 0) = 0 THEN 0
|
|
36195
|
+
ELSE (seq_scan::numeric / NULLIF(idx_scan, 0))::numeric(10, 2)::float8
|
|
36196
|
+
END AS ratio
|
|
36197
|
+
FROM pg_catalog.pg_stat_user_tables
|
|
36198
|
+
WHERE n_live_tup >= $1
|
|
36199
|
+
${schemaFilter}
|
|
36200
|
+
ORDER BY seq_scan DESC NULLS LAST
|
|
36201
|
+
LIMIT $2`,
|
|
36202
|
+
params
|
|
36203
|
+
);
|
|
36204
|
+
}
|
|
36205
|
+
},
|
|
36206
|
+
{
|
|
36207
|
+
name: "pg_unused_indexes",
|
|
36208
|
+
description: "Indexes that have never been scanned or have very low usage. Each unused index costs write amplification (every INSERT/UPDATE maintains it) and disk space. Excludes primary keys and unique constraints (which are load-bearing even with zero scans). Use this before adding new indexes \u2014 sometimes the fix is to drop a dead one.",
|
|
36209
|
+
annotations: {
|
|
36210
|
+
title: "Find unused indexes",
|
|
36211
|
+
readOnlyHint: true,
|
|
36212
|
+
destructiveHint: false,
|
|
36213
|
+
idempotentHint: true,
|
|
36214
|
+
openWorldHint: true
|
|
36215
|
+
},
|
|
36216
|
+
inputSchema: external_exports3.object({
|
|
36217
|
+
schema: identSchema2.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
36218
|
+
maxScans: external_exports3.number().int().min(0).default(10).describe("Include indexes with scan count <= this (default 10). Use 0 for 'never scanned'."),
|
|
36219
|
+
limit: external_exports3.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
|
|
36220
|
+
}),
|
|
36221
|
+
handler: async (input) => {
|
|
36222
|
+
const { schema, maxScans, limit } = input;
|
|
36223
|
+
const schemaFilter = schema ? "AND s.schemaname = $3" : "AND s.schemaname NOT IN ('pg_catalog', 'information_schema') AND s.schemaname NOT LIKE 'pg_%'";
|
|
36224
|
+
const params = [maxScans, limit];
|
|
36225
|
+
if (schema) params.push(schema);
|
|
36226
|
+
return runInternal(
|
|
36227
|
+
`SELECT
|
|
36228
|
+
s.schemaname AS schema,
|
|
36229
|
+
s.relname AS "table",
|
|
36230
|
+
s.indexrelname AS "index",
|
|
36231
|
+
s.idx_scan::text AS scans,
|
|
36232
|
+
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size_pretty,
|
|
36233
|
+
pg_relation_size(s.indexrelid)::text AS size_bytes,
|
|
36234
|
+
pg_catalog.pg_get_indexdef(s.indexrelid) AS definition
|
|
36235
|
+
FROM pg_catalog.pg_stat_user_indexes s
|
|
36236
|
+
JOIN pg_catalog.pg_index i ON i.indexrelid = s.indexrelid
|
|
36237
|
+
WHERE s.idx_scan <= $1
|
|
36238
|
+
AND NOT i.indisunique
|
|
36239
|
+
AND NOT i.indisprimary
|
|
36240
|
+
${schemaFilter}
|
|
36241
|
+
ORDER BY pg_relation_size(s.indexrelid) DESC
|
|
36242
|
+
LIMIT $2`,
|
|
36243
|
+
params
|
|
36244
|
+
);
|
|
36245
|
+
}
|
|
35678
36246
|
}
|
|
35679
36247
|
];
|
|
36248
|
+
function compareVersions(a, b) {
|
|
36249
|
+
const parse3 = (v) => v.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
36250
|
+
const aa = parse3(a);
|
|
36251
|
+
const bb = parse3(b);
|
|
36252
|
+
const len = Math.max(aa.length, bb.length);
|
|
36253
|
+
for (let i = 0; i < len; i++) {
|
|
36254
|
+
const diff = (aa[i] ?? 0) - (bb[i] ?? 0);
|
|
36255
|
+
if (diff !== 0) return diff;
|
|
36256
|
+
}
|
|
36257
|
+
return 0;
|
|
36258
|
+
}
|
|
35680
36259
|
|
|
35681
36260
|
// src/index.ts
|
|
35682
|
-
var version2 = true ? "0.
|
|
36261
|
+
var version2 = true ? "0.3.0" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
35683
36262
|
var subcommand = process.argv[2];
|
|
35684
36263
|
if (subcommand === "version" || subcommand === "--version") {
|
|
35685
36264
|
console.log(version2);
|
|
35686
36265
|
process.exit(0);
|
|
35687
36266
|
}
|
|
35688
|
-
var allTools = [...queryTools, ...schemaTools, ...explainTools, ...healthTools];
|
|
36267
|
+
var allTools = [...queryTools, ...schemaTools, ...explainTools, ...healthTools, ...statsTools, ...adminTools];
|
|
35689
36268
|
var server = new McpServer({
|
|
35690
36269
|
name: "@yawlabs/postgres-mcp",
|
|
35691
36270
|
version: version2
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/postgres-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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>",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"start": "node dist/index.js",
|
|
34
34
|
"test": "npm run build && node --test dist/**/*.test.js",
|
|
35
35
|
"test:ci": "npm run test",
|
|
36
|
+
"test:integration": "npm run build && node --test dist/**/*.integration.test.js",
|
|
36
37
|
"lint": "biome check src/",
|
|
37
38
|
"lint:fix": "biome check --write src/",
|
|
38
39
|
"prepublishOnly": "npm run build"
|