@yawlabs/postgres-mcp 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -2
- package/dist/index.js +613 -32
- package/package.json +3 -2
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,275 @@ 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 NOT starts_with(r.rolname, 'pg_')";
|
|
35443
|
+
return runInternal(
|
|
35444
|
+
// Cast member_of to text[] so node-pg parses it into a JS array.
|
|
35445
|
+
// Without the cast, it comes back as the postgres text form `{a,b}`.
|
|
35446
|
+
`SELECT
|
|
35447
|
+
r.rolname AS name,
|
|
35448
|
+
r.rolcanlogin AS can_login,
|
|
35449
|
+
r.rolsuper AS superuser,
|
|
35450
|
+
r.rolcreatedb AS createdb,
|
|
35451
|
+
r.rolcreaterole AS createrole,
|
|
35452
|
+
r.rolreplication AS replication,
|
|
35453
|
+
r.rolbypassrls AS bypass_rls,
|
|
35454
|
+
COALESCE(
|
|
35455
|
+
(SELECT array_agg(g.rolname::text ORDER BY g.rolname)
|
|
35456
|
+
FROM pg_catalog.pg_auth_members m
|
|
35457
|
+
JOIN pg_catalog.pg_roles g ON g.oid = m.roleid
|
|
35458
|
+
WHERE m.member = r.oid),
|
|
35459
|
+
ARRAY[]::text[]
|
|
35460
|
+
) AS member_of
|
|
35461
|
+
FROM pg_catalog.pg_roles r
|
|
35462
|
+
${filter}
|
|
35463
|
+
ORDER BY r.rolname`
|
|
35464
|
+
);
|
|
35465
|
+
}
|
|
35466
|
+
},
|
|
35467
|
+
{
|
|
35468
|
+
name: "pg_table_privileges",
|
|
35469
|
+
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.",
|
|
35470
|
+
annotations: {
|
|
35471
|
+
title: "Show table privileges",
|
|
35472
|
+
readOnlyHint: true,
|
|
35473
|
+
destructiveHint: false,
|
|
35474
|
+
idempotentHint: true,
|
|
35475
|
+
openWorldHint: true
|
|
35476
|
+
},
|
|
35477
|
+
inputSchema: external_exports3.object({
|
|
35478
|
+
schema: external_exports3.string().min(1).max(63).default("public").describe("Schema name (defaults to 'public')."),
|
|
35479
|
+
table: external_exports3.string().min(1).max(63).optional().describe("Table name. Omit to list privileges for all tables in the schema.")
|
|
35480
|
+
}),
|
|
35481
|
+
handler: async (input) => {
|
|
35482
|
+
const { schema, table } = input;
|
|
35483
|
+
const tableFilter = table ? "AND table_name = $2" : "";
|
|
35484
|
+
const params = [schema];
|
|
35485
|
+
if (table) params.push(table);
|
|
35486
|
+
return runInternal(
|
|
35487
|
+
`SELECT
|
|
35488
|
+
table_name AS "table",
|
|
35489
|
+
grantee,
|
|
35490
|
+
privilege_type,
|
|
35491
|
+
is_grantable::boolean AS is_grantable
|
|
35492
|
+
FROM information_schema.table_privileges
|
|
35493
|
+
WHERE table_schema = $1
|
|
35494
|
+
${tableFilter}
|
|
35495
|
+
ORDER BY table_name, grantee, privilege_type`,
|
|
35496
|
+
params
|
|
35497
|
+
);
|
|
35498
|
+
}
|
|
35499
|
+
},
|
|
35500
|
+
{
|
|
35501
|
+
name: "pg_kill",
|
|
35502
|
+
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.",
|
|
35503
|
+
annotations: {
|
|
35504
|
+
title: "Cancel or terminate a backend",
|
|
35505
|
+
readOnlyHint: false,
|
|
35506
|
+
destructiveHint: true,
|
|
35507
|
+
idempotentHint: false,
|
|
35508
|
+
openWorldHint: true
|
|
35509
|
+
},
|
|
35510
|
+
inputSchema: external_exports3.object({
|
|
35511
|
+
pid: external_exports3.number().int().min(1).describe("Backend PID to signal."),
|
|
35512
|
+
mode: external_exports3.enum(["cancel", "terminate"]).default("cancel").describe("`cancel` aborts the current query; `terminate` closes the connection entirely.")
|
|
35513
|
+
}),
|
|
35514
|
+
handler: async (input) => {
|
|
35515
|
+
const { pid, mode } = input;
|
|
35516
|
+
if (!isWritesAllowed()) {
|
|
35517
|
+
return {
|
|
35518
|
+
ok: false,
|
|
35519
|
+
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."
|
|
35520
|
+
};
|
|
35521
|
+
}
|
|
35522
|
+
const fn = mode === "terminate" ? "pg_terminate_backend" : "pg_cancel_backend";
|
|
35523
|
+
const result = await runInternal(`SELECT ${fn}($1) AS signaled`, [pid]);
|
|
35524
|
+
if (!result.ok) return result;
|
|
35525
|
+
const signaled = result.data?.[0]?.signaled === true;
|
|
35526
|
+
return {
|
|
35527
|
+
ok: true,
|
|
35528
|
+
data: {
|
|
35529
|
+
pid,
|
|
35530
|
+
mode,
|
|
35531
|
+
signaled,
|
|
35532
|
+
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.`
|
|
35533
|
+
}
|
|
35534
|
+
};
|
|
35535
|
+
}
|
|
35536
|
+
},
|
|
35537
|
+
{
|
|
35538
|
+
name: "pg_replication_status",
|
|
35539
|
+
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.",
|
|
35540
|
+
annotations: {
|
|
35541
|
+
title: "Replication status",
|
|
35542
|
+
readOnlyHint: true,
|
|
35543
|
+
destructiveHint: false,
|
|
35544
|
+
idempotentHint: true,
|
|
35545
|
+
openWorldHint: true
|
|
35546
|
+
},
|
|
35547
|
+
inputSchema: external_exports3.object({}),
|
|
35548
|
+
handler: async () => {
|
|
35549
|
+
const [slotsRes, replicasRes, walRes] = await Promise.all([
|
|
35550
|
+
runInternal(
|
|
35551
|
+
`SELECT
|
|
35552
|
+
slot_name, slot_type, active,
|
|
35553
|
+
restart_lsn::text AS restart_lsn,
|
|
35554
|
+
confirmed_flush_lsn::text AS confirmed_flush_lsn,
|
|
35555
|
+
wal_status, database, plugin
|
|
35556
|
+
FROM pg_catalog.pg_replication_slots
|
|
35557
|
+
ORDER BY slot_name`
|
|
35558
|
+
),
|
|
35559
|
+
runInternal(
|
|
35560
|
+
`SELECT
|
|
35561
|
+
application_name,
|
|
35562
|
+
client_addr::text AS client_addr,
|
|
35563
|
+
state,
|
|
35564
|
+
sync_state,
|
|
35565
|
+
EXTRACT(EPOCH FROM write_lag)::numeric(10, 2)::float8 AS write_lag_seconds,
|
|
35566
|
+
EXTRACT(EPOCH FROM flush_lag)::numeric(10, 2)::float8 AS flush_lag_seconds,
|
|
35567
|
+
EXTRACT(EPOCH FROM replay_lag)::numeric(10, 2)::float8 AS replay_lag_seconds
|
|
35568
|
+
FROM pg_catalog.pg_stat_replication
|
|
35569
|
+
ORDER BY application_name`
|
|
35570
|
+
),
|
|
35571
|
+
runInternal(
|
|
35572
|
+
`SELECT
|
|
35573
|
+
pg_is_in_recovery() AS is_in_recovery,
|
|
35574
|
+
CASE
|
|
35575
|
+
WHEN pg_is_in_recovery() THEN pg_last_wal_receive_lsn()::text
|
|
35576
|
+
ELSE pg_current_wal_lsn()::text
|
|
35577
|
+
END AS wal_position`
|
|
35578
|
+
)
|
|
35579
|
+
]);
|
|
35580
|
+
if (!slotsRes.ok) return slotsRes;
|
|
35581
|
+
if (!replicasRes.ok) return replicasRes;
|
|
35582
|
+
if (!walRes.ok) return walRes;
|
|
35583
|
+
return {
|
|
35584
|
+
ok: true,
|
|
35585
|
+
data: {
|
|
35586
|
+
is_replica: walRes.data?.[0]?.is_in_recovery ?? false,
|
|
35587
|
+
wal_position: walRes.data?.[0]?.wal_position ?? null,
|
|
35588
|
+
slots: slotsRes.data ?? [],
|
|
35589
|
+
replicas: replicasRes.data ?? []
|
|
35590
|
+
}
|
|
35591
|
+
};
|
|
35592
|
+
}
|
|
35593
|
+
},
|
|
35594
|
+
{
|
|
35595
|
+
name: "pg_table_bloat",
|
|
35596
|
+
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.",
|
|
35597
|
+
annotations: {
|
|
35598
|
+
title: "Estimate table bloat",
|
|
35599
|
+
readOnlyHint: true,
|
|
35600
|
+
destructiveHint: false,
|
|
35601
|
+
idempotentHint: true,
|
|
35602
|
+
openWorldHint: true
|
|
35603
|
+
},
|
|
35604
|
+
inputSchema: external_exports3.object({
|
|
35605
|
+
schema: external_exports3.string().min(1).max(63).optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
35606
|
+
minDeadRatio: external_exports3.number().min(0).max(1).default(0.1).describe("Minimum dead/live ratio to include (default 0.1 = 10%)."),
|
|
35607
|
+
limit: external_exports3.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
|
|
35608
|
+
}),
|
|
35609
|
+
handler: async (input) => {
|
|
35610
|
+
const { schema, minDeadRatio, limit } = input;
|
|
35611
|
+
const schemaFilter = schema ? "AND schemaname = $3" : "AND schemaname NOT IN ('pg_catalog', 'information_schema') AND schemaname NOT LIKE 'pg_%'";
|
|
35612
|
+
const params = [minDeadRatio, limit];
|
|
35613
|
+
if (schema) params.push(schema);
|
|
35614
|
+
return runInternal(
|
|
35615
|
+
`SELECT
|
|
35616
|
+
schemaname AS schema,
|
|
35617
|
+
relname AS "table",
|
|
35618
|
+
n_live_tup::text AS live_tuples,
|
|
35619
|
+
n_dead_tup::text AS dead_tuples,
|
|
35620
|
+
CASE
|
|
35621
|
+
WHEN n_live_tup = 0 THEN 0
|
|
35622
|
+
ELSE (n_dead_tup::float8 / GREATEST(n_live_tup, 1))::numeric(6, 3)::float8
|
|
35623
|
+
END AS dead_ratio,
|
|
35624
|
+
pg_size_pretty(pg_total_relation_size(relid)) AS size_pretty,
|
|
35625
|
+
pg_total_relation_size(relid)::text AS size_bytes,
|
|
35626
|
+
last_vacuum::text AS last_vacuum,
|
|
35627
|
+
last_autovacuum::text AS last_autovacuum,
|
|
35628
|
+
last_analyze::text AS last_analyze
|
|
35629
|
+
FROM pg_catalog.pg_stat_user_tables
|
|
35630
|
+
WHERE (n_dead_tup::float8 / GREATEST(n_live_tup, 1)) >= $1
|
|
35631
|
+
${schemaFilter}
|
|
35632
|
+
ORDER BY n_dead_tup DESC
|
|
35633
|
+
LIMIT $2`,
|
|
35634
|
+
params
|
|
35635
|
+
);
|
|
35636
|
+
}
|
|
35637
|
+
}
|
|
35638
|
+
];
|
|
35639
|
+
|
|
35367
35640
|
// src/tools/explain.ts
|
|
35641
|
+
var paramValue = external_exports3.lazy(
|
|
35642
|
+
() => external_exports3.union([external_exports3.string(), external_exports3.number(), external_exports3.boolean(), external_exports3.null(), external_exports3.array(paramValue), external_exports3.record(external_exports3.string(), paramValue)])
|
|
35643
|
+
);
|
|
35368
35644
|
var explainTools = [
|
|
35369
35645
|
{
|
|
35370
35646
|
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`.",
|
|
35647
|
+
description: "Get the query plan for a SQL statement. By default, this uses plain EXPLAIN (no execution). Set `analyze: true` to run the query with EXPLAIN ANALYZE \u2014 for non-SELECT statements, ALLOW_WRITES=1 is required (since ANALYZE actually executes the statement). Format is `text` (default) or `json`. Pass the raw SQL (not an EXPLAIN-prefixed statement).",
|
|
35372
35648
|
annotations: {
|
|
35373
35649
|
title: "Explain query plan",
|
|
35374
35650
|
readOnlyHint: false,
|
|
@@ -35377,13 +35653,19 @@ var explainTools = [
|
|
|
35377
35653
|
openWorldHint: true
|
|
35378
35654
|
},
|
|
35379
35655
|
inputSchema: external_exports3.object({
|
|
35380
|
-
sql: external_exports3.string().min(1).describe("The SQL statement to explain."),
|
|
35656
|
+
sql: external_exports3.string().min(1).max(1e6).describe("The SQL statement to explain. Do NOT prefix with EXPLAIN."),
|
|
35381
35657
|
analyze: external_exports3.boolean().default(false).describe("Run EXPLAIN ANALYZE (actually executes the query)."),
|
|
35382
35658
|
format: external_exports3.enum(["text", "json"]).default("text").describe("Output format."),
|
|
35383
|
-
params: external_exports3.array(
|
|
35659
|
+
params: external_exports3.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
|
|
35384
35660
|
}),
|
|
35385
35661
|
handler: async (input) => {
|
|
35386
35662
|
const { sql, analyze, format, params } = input;
|
|
35663
|
+
if (/^\s*EXPLAIN\b/i.test(sql)) {
|
|
35664
|
+
return {
|
|
35665
|
+
ok: false,
|
|
35666
|
+
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."
|
|
35667
|
+
};
|
|
35668
|
+
}
|
|
35387
35669
|
const flags = [];
|
|
35388
35670
|
if (analyze) flags.push("ANALYZE");
|
|
35389
35671
|
if (format === "json") flags.push("FORMAT JSON");
|
|
@@ -35414,8 +35696,11 @@ var healthTools = [
|
|
|
35414
35696
|
idempotentHint: true,
|
|
35415
35697
|
openWorldHint: true
|
|
35416
35698
|
},
|
|
35417
|
-
inputSchema: external_exports3.object({
|
|
35418
|
-
|
|
35699
|
+
inputSchema: external_exports3.object({
|
|
35700
|
+
activeQueryLimit: external_exports3.number().int().min(1).max(100).default(10).describe("Max active queries to return (default 10, max 100).")
|
|
35701
|
+
}),
|
|
35702
|
+
handler: async (input) => {
|
|
35703
|
+
const { activeQueryLimit } = input;
|
|
35419
35704
|
const [versionRes, sizeRes, connsRes, activeRes, tableCountRes] = await Promise.all([
|
|
35420
35705
|
runInternal(`SELECT version() AS version`),
|
|
35421
35706
|
runInternal(
|
|
@@ -35446,7 +35731,8 @@ var healthTools = [
|
|
|
35446
35731
|
AND state <> 'idle'
|
|
35447
35732
|
AND pid <> pg_backend_pid()
|
|
35448
35733
|
ORDER BY query_start ASC NULLS LAST
|
|
35449
|
-
LIMIT
|
|
35734
|
+
LIMIT $1`,
|
|
35735
|
+
[activeQueryLimit]
|
|
35450
35736
|
),
|
|
35451
35737
|
runInternal(
|
|
35452
35738
|
`SELECT count(*)::text AS count
|
|
@@ -35474,10 +35760,13 @@ var healthTools = [
|
|
|
35474
35760
|
];
|
|
35475
35761
|
|
|
35476
35762
|
// src/tools/query.ts
|
|
35763
|
+
var paramValue2 = external_exports3.lazy(
|
|
35764
|
+
() => external_exports3.union([external_exports3.string(), external_exports3.number(), external_exports3.boolean(), external_exports3.null(), external_exports3.array(paramValue2), external_exports3.record(external_exports3.string(), paramValue2)])
|
|
35765
|
+
);
|
|
35477
35766
|
var queryTools = [
|
|
35478
35767
|
{
|
|
35479
35768
|
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.",
|
|
35769
|
+
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
35770
|
annotations: {
|
|
35482
35771
|
title: "Run SQL query",
|
|
35483
35772
|
readOnlyHint: false,
|
|
@@ -35487,8 +35776,8 @@ var queryTools = [
|
|
|
35487
35776
|
openWorldHint: true
|
|
35488
35777
|
},
|
|
35489
35778
|
inputSchema: external_exports3.object({
|
|
35490
|
-
sql: external_exports3.string().min(1).describe("The SQL statement to execute."),
|
|
35491
|
-
params: external_exports3.array(
|
|
35779
|
+
sql: external_exports3.string().min(1).max(1e6).describe("The SQL statement to execute. Hard cap of 1 MB."),
|
|
35780
|
+
params: external_exports3.array(paramValue2).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
|
|
35492
35781
|
}),
|
|
35493
35782
|
handler: async (input) => {
|
|
35494
35783
|
const { sql, params } = input;
|
|
@@ -35501,12 +35790,7 @@ var queryTools = [
|
|
|
35501
35790
|
];
|
|
35502
35791
|
|
|
35503
35792
|
// 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
|
-
}
|
|
35793
|
+
var identSchema = external_exports3.string().min(1).max(63);
|
|
35510
35794
|
var schemaTools = [
|
|
35511
35795
|
{
|
|
35512
35796
|
name: "pg_list_schemas",
|
|
@@ -35534,7 +35818,7 @@ var schemaTools = [
|
|
|
35534
35818
|
},
|
|
35535
35819
|
{
|
|
35536
35820
|
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.",
|
|
35821
|
+
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
35822
|
annotations: {
|
|
35539
35823
|
title: "List tables in a schema",
|
|
35540
35824
|
readOnlyHint: true,
|
|
@@ -35543,12 +35827,13 @@ var schemaTools = [
|
|
|
35543
35827
|
openWorldHint: true
|
|
35544
35828
|
},
|
|
35545
35829
|
inputSchema: external_exports3.object({
|
|
35546
|
-
schema:
|
|
35547
|
-
includeViews: external_exports3.boolean().default(false).describe("If true, include views and materialized views.")
|
|
35830
|
+
schema: identSchema.default("public").describe("Schema name (defaults to 'public')."),
|
|
35831
|
+
includeViews: external_exports3.boolean().default(false).describe("If true, include views and materialized views."),
|
|
35832
|
+
limit: external_exports3.number().int().min(1).max(1e4).default(500).describe("Max rows to return (default 500, max 10000)."),
|
|
35833
|
+
offset: external_exports3.number().int().min(0).default(0).describe("Rows to skip for pagination (default 0).")
|
|
35548
35834
|
}),
|
|
35549
35835
|
handler: async (input) => {
|
|
35550
|
-
const { schema, includeViews } = input;
|
|
35551
|
-
validateIdent(schema, "schema");
|
|
35836
|
+
const { schema, includeViews, limit, offset } = input;
|
|
35552
35837
|
const kinds = includeViews ? "('r', 'v', 'm', 'f', 'p')" : "('r', 'f', 'p')";
|
|
35553
35838
|
return runInternal(
|
|
35554
35839
|
`SELECT
|
|
@@ -35566,8 +35851,9 @@ var schemaTools = [
|
|
|
35566
35851
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
35567
35852
|
WHERE n.nspname = $1
|
|
35568
35853
|
AND c.relkind IN ${kinds}
|
|
35569
|
-
ORDER BY c.relname
|
|
35570
|
-
|
|
35854
|
+
ORDER BY c.relname
|
|
35855
|
+
LIMIT $2 OFFSET $3`,
|
|
35856
|
+
[schema, limit, offset]
|
|
35571
35857
|
);
|
|
35572
35858
|
}
|
|
35573
35859
|
},
|
|
@@ -35582,13 +35868,11 @@ var schemaTools = [
|
|
|
35582
35868
|
openWorldHint: true
|
|
35583
35869
|
},
|
|
35584
35870
|
inputSchema: external_exports3.object({
|
|
35585
|
-
schema:
|
|
35586
|
-
table:
|
|
35871
|
+
schema: identSchema.default("public").describe("Schema name (defaults to 'public')."),
|
|
35872
|
+
table: identSchema.describe("Table name.")
|
|
35587
35873
|
}),
|
|
35588
35874
|
handler: async (input) => {
|
|
35589
35875
|
const { schema, table } = input;
|
|
35590
|
-
validateIdent(schema, "schema");
|
|
35591
|
-
validateIdent(table, "table");
|
|
35592
35876
|
const columnsQuery = `
|
|
35593
35877
|
SELECT
|
|
35594
35878
|
a.attname AS name,
|
|
@@ -35620,10 +35904,10 @@ var schemaTools = [
|
|
|
35620
35904
|
const foreignKeysQuery = `
|
|
35621
35905
|
SELECT
|
|
35622
35906
|
con.conname AS constraint_name,
|
|
35623
|
-
array_agg(att.attname ORDER BY u.attposition) AS columns,
|
|
35907
|
+
array_agg(att.attname::text ORDER BY u.attposition) AS columns,
|
|
35624
35908
|
cl.relname AS foreign_table,
|
|
35625
35909
|
fn.nspname AS foreign_schema,
|
|
35626
|
-
array_agg(fatt.attname ORDER BY u.attposition) AS foreign_columns
|
|
35910
|
+
array_agg(fatt.attname::text ORDER BY u.attposition) AS foreign_columns
|
|
35627
35911
|
FROM pg_catalog.pg_constraint con
|
|
35628
35912
|
JOIN pg_catalog.pg_class c ON c.oid = con.conrelid
|
|
35629
35913
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
@@ -35663,6 +35947,10 @@ var schemaTools = [
|
|
|
35663
35947
|
if (!cols.data || cols.data.length === 0) {
|
|
35664
35948
|
return { ok: false, error: `Table "${schema}"."${table}" not found.` };
|
|
35665
35949
|
}
|
|
35950
|
+
const warnings = [];
|
|
35951
|
+
if (!pk.ok) warnings.push(`primary_key fetch failed: ${pk.error}`);
|
|
35952
|
+
if (!fks.ok) warnings.push(`foreign_keys fetch failed: ${fks.error}`);
|
|
35953
|
+
if (!idxs.ok) warnings.push(`indexes fetch failed: ${idxs.error}`);
|
|
35666
35954
|
return {
|
|
35667
35955
|
ok: true,
|
|
35668
35956
|
data: {
|
|
@@ -35671,21 +35959,314 @@ var schemaTools = [
|
|
|
35671
35959
|
columns: cols.data,
|
|
35672
35960
|
primary_key: pk.ok ? (pk.data ?? []).map((r) => r.column_name) : [],
|
|
35673
35961
|
foreign_keys: fks.ok ? fks.data : [],
|
|
35674
|
-
indexes: idxs.ok ? idxs.data : []
|
|
35962
|
+
indexes: idxs.ok ? idxs.data : [],
|
|
35963
|
+
...warnings.length > 0 ? { _warnings: warnings } : {}
|
|
35675
35964
|
}
|
|
35676
35965
|
};
|
|
35677
35966
|
}
|
|
35967
|
+
},
|
|
35968
|
+
{
|
|
35969
|
+
name: "pg_list_views",
|
|
35970
|
+
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.",
|
|
35971
|
+
annotations: {
|
|
35972
|
+
title: "List views with definitions",
|
|
35973
|
+
readOnlyHint: true,
|
|
35974
|
+
destructiveHint: false,
|
|
35975
|
+
idempotentHint: true,
|
|
35976
|
+
openWorldHint: true
|
|
35977
|
+
},
|
|
35978
|
+
inputSchema: external_exports3.object({
|
|
35979
|
+
schema: identSchema.default("public").describe("Schema name (defaults to 'public')."),
|
|
35980
|
+
includeMaterialized: external_exports3.boolean().default(true).describe("If true, include materialized views.")
|
|
35981
|
+
}),
|
|
35982
|
+
handler: async (input) => {
|
|
35983
|
+
const { schema, includeMaterialized } = input;
|
|
35984
|
+
const kinds = includeMaterialized ? "('v', 'm')" : "('v')";
|
|
35985
|
+
return runInternal(
|
|
35986
|
+
`SELECT
|
|
35987
|
+
c.relname AS name,
|
|
35988
|
+
CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized_view' END AS type,
|
|
35989
|
+
pg_catalog.pg_get_viewdef(c.oid, true) AS definition
|
|
35990
|
+
FROM pg_catalog.pg_class c
|
|
35991
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
35992
|
+
WHERE n.nspname = $1
|
|
35993
|
+
AND c.relkind IN ${kinds}
|
|
35994
|
+
ORDER BY c.relname`,
|
|
35995
|
+
[schema]
|
|
35996
|
+
);
|
|
35997
|
+
}
|
|
35998
|
+
},
|
|
35999
|
+
{
|
|
36000
|
+
name: "pg_list_functions",
|
|
36001
|
+
description: "List functions, procedures, and aggregates in a schema. Returns name, arguments, return type, kind (function/procedure/aggregate/window), and implementation language.",
|
|
36002
|
+
annotations: {
|
|
36003
|
+
title: "List functions and procedures",
|
|
36004
|
+
readOnlyHint: true,
|
|
36005
|
+
destructiveHint: false,
|
|
36006
|
+
idempotentHint: true,
|
|
36007
|
+
openWorldHint: true
|
|
36008
|
+
},
|
|
36009
|
+
inputSchema: external_exports3.object({
|
|
36010
|
+
schema: identSchema.default("public").describe("Schema name (defaults to 'public').")
|
|
36011
|
+
}),
|
|
36012
|
+
handler: async (input) => {
|
|
36013
|
+
const { schema } = input;
|
|
36014
|
+
return runInternal(
|
|
36015
|
+
`SELECT
|
|
36016
|
+
p.proname AS name,
|
|
36017
|
+
pg_catalog.pg_get_function_arguments(p.oid) AS arguments,
|
|
36018
|
+
pg_catalog.pg_get_function_result(p.oid) AS return_type,
|
|
36019
|
+
CASE p.prokind
|
|
36020
|
+
WHEN 'f' THEN 'function'
|
|
36021
|
+
WHEN 'p' THEN 'procedure'
|
|
36022
|
+
WHEN 'a' THEN 'aggregate'
|
|
36023
|
+
WHEN 'w' THEN 'window'
|
|
36024
|
+
ELSE p.prokind::text
|
|
36025
|
+
END AS kind,
|
|
36026
|
+
l.lanname AS language
|
|
36027
|
+
FROM pg_catalog.pg_proc p
|
|
36028
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
36029
|
+
JOIN pg_catalog.pg_language l ON l.oid = p.prolang
|
|
36030
|
+
WHERE n.nspname = $1
|
|
36031
|
+
ORDER BY p.proname, p.oid`,
|
|
36032
|
+
[schema]
|
|
36033
|
+
);
|
|
36034
|
+
}
|
|
36035
|
+
},
|
|
36036
|
+
{
|
|
36037
|
+
name: "pg_list_extensions",
|
|
36038
|
+
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.",
|
|
36039
|
+
annotations: {
|
|
36040
|
+
title: "List installed extensions",
|
|
36041
|
+
readOnlyHint: true,
|
|
36042
|
+
destructiveHint: false,
|
|
36043
|
+
idempotentHint: true,
|
|
36044
|
+
openWorldHint: true
|
|
36045
|
+
},
|
|
36046
|
+
inputSchema: external_exports3.object({}),
|
|
36047
|
+
handler: async () => {
|
|
36048
|
+
return runInternal(
|
|
36049
|
+
`SELECT
|
|
36050
|
+
e.extname AS name,
|
|
36051
|
+
e.extversion AS version,
|
|
36052
|
+
n.nspname AS schema,
|
|
36053
|
+
d.description
|
|
36054
|
+
FROM pg_catalog.pg_extension e
|
|
36055
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace
|
|
36056
|
+
LEFT JOIN pg_catalog.pg_description d ON d.objoid = e.oid AND d.classoid = 'pg_extension'::regclass
|
|
36057
|
+
ORDER BY e.extname`
|
|
36058
|
+
);
|
|
36059
|
+
}
|
|
36060
|
+
},
|
|
36061
|
+
{
|
|
36062
|
+
name: "pg_search_columns",
|
|
36063
|
+
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'.",
|
|
36064
|
+
annotations: {
|
|
36065
|
+
title: "Search columns by name",
|
|
36066
|
+
readOnlyHint: true,
|
|
36067
|
+
destructiveHint: false,
|
|
36068
|
+
idempotentHint: true,
|
|
36069
|
+
openWorldHint: true
|
|
36070
|
+
},
|
|
36071
|
+
inputSchema: external_exports3.object({
|
|
36072
|
+
pattern: external_exports3.string().min(1).describe("LIKE pattern. Use '%' for wildcard: 'user_id', '%email%', 'created_%'."),
|
|
36073
|
+
schema: identSchema.optional().describe("Limit to this schema. If omitted, searches all user schemas."),
|
|
36074
|
+
limit: external_exports3.number().int().min(1).max(1e3).default(100).describe("Max rows to return (default 100).")
|
|
36075
|
+
}),
|
|
36076
|
+
handler: async (input) => {
|
|
36077
|
+
const { pattern, schema, limit } = input;
|
|
36078
|
+
const schemaFilter = schema ? "AND n.nspname = $3" : "AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'";
|
|
36079
|
+
const params = [pattern, limit];
|
|
36080
|
+
if (schema) params.push(schema);
|
|
36081
|
+
return runInternal(
|
|
36082
|
+
`SELECT
|
|
36083
|
+
n.nspname AS schema,
|
|
36084
|
+
c.relname AS "table",
|
|
36085
|
+
a.attname AS "column",
|
|
36086
|
+
pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
|
|
36087
|
+
NOT a.attnotnull AS nullable
|
|
36088
|
+
FROM pg_catalog.pg_attribute a
|
|
36089
|
+
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
36090
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
36091
|
+
WHERE a.attname ILIKE $1
|
|
36092
|
+
AND a.attnum > 0
|
|
36093
|
+
AND NOT a.attisdropped
|
|
36094
|
+
AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
|
36095
|
+
${schemaFilter}
|
|
36096
|
+
ORDER BY n.nspname, c.relname, a.attnum
|
|
36097
|
+
LIMIT $2`,
|
|
36098
|
+
params
|
|
36099
|
+
);
|
|
36100
|
+
}
|
|
36101
|
+
}
|
|
36102
|
+
];
|
|
36103
|
+
|
|
36104
|
+
// src/tools/stats.ts
|
|
36105
|
+
var identSchema2 = external_exports3.string().min(1).max(63);
|
|
36106
|
+
var statsTools = [
|
|
36107
|
+
{
|
|
36108
|
+
name: "pg_top_queries",
|
|
36109
|
+
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.",
|
|
36110
|
+
annotations: {
|
|
36111
|
+
title: "Top queries by execution time",
|
|
36112
|
+
readOnlyHint: true,
|
|
36113
|
+
destructiveHint: false,
|
|
36114
|
+
idempotentHint: true,
|
|
36115
|
+
openWorldHint: true
|
|
36116
|
+
},
|
|
36117
|
+
inputSchema: external_exports3.object({
|
|
36118
|
+
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)."),
|
|
36119
|
+
limit: external_exports3.number().int().min(1).max(100).default(20).describe("Number of rows to return (default 20).")
|
|
36120
|
+
}),
|
|
36121
|
+
handler: async (input) => {
|
|
36122
|
+
const { orderBy, limit } = input;
|
|
36123
|
+
const check2 = await runInternal(
|
|
36124
|
+
`SELECT EXISTS (
|
|
36125
|
+
SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'
|
|
36126
|
+
) AS installed`
|
|
36127
|
+
);
|
|
36128
|
+
if (!check2.ok) return check2;
|
|
36129
|
+
if (!check2.data?.[0]?.installed) {
|
|
36130
|
+
return {
|
|
36131
|
+
ok: false,
|
|
36132
|
+
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."
|
|
36133
|
+
};
|
|
36134
|
+
}
|
|
36135
|
+
const versionRes = await runInternal(
|
|
36136
|
+
`SELECT extversion AS version FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements'`
|
|
36137
|
+
);
|
|
36138
|
+
const extVersion = versionRes.ok ? versionRes.data?.[0]?.version ?? "0" : "0";
|
|
36139
|
+
const useExecSuffix = compareVersions(extVersion, "1.8") >= 0;
|
|
36140
|
+
const totalCol = useExecSuffix ? "total_exec_time" : "total_time";
|
|
36141
|
+
const meanCol = useExecSuffix ? "mean_exec_time" : "mean_time";
|
|
36142
|
+
const minCol = useExecSuffix ? "min_exec_time" : "min_time";
|
|
36143
|
+
const maxCol = useExecSuffix ? "max_exec_time" : "max_time";
|
|
36144
|
+
const orderCol = orderBy === "total_time" ? totalCol : orderBy === "mean_time" ? meanCol : "calls";
|
|
36145
|
+
return runInternal(
|
|
36146
|
+
`SELECT
|
|
36147
|
+
query,
|
|
36148
|
+
calls::text AS calls,
|
|
36149
|
+
${totalCol}::numeric(18, 2)::float8 AS total_time_ms,
|
|
36150
|
+
${meanCol}::numeric(18, 2)::float8 AS mean_time_ms,
|
|
36151
|
+
${minCol}::numeric(18, 2)::float8 AS min_time_ms,
|
|
36152
|
+
${maxCol}::numeric(18, 2)::float8 AS max_time_ms,
|
|
36153
|
+
rows::text AS rows,
|
|
36154
|
+
CASE
|
|
36155
|
+
WHEN (shared_blks_hit + shared_blks_read) > 0
|
|
36156
|
+
THEN (shared_blks_hit::float8 / (shared_blks_hit + shared_blks_read) * 100)::numeric(5, 2)::float8
|
|
36157
|
+
ELSE NULL
|
|
36158
|
+
END AS hit_percent
|
|
36159
|
+
FROM pg_stat_statements
|
|
36160
|
+
ORDER BY ${orderCol} DESC NULLS LAST
|
|
36161
|
+
LIMIT $1`,
|
|
36162
|
+
[limit]
|
|
36163
|
+
);
|
|
36164
|
+
}
|
|
36165
|
+
},
|
|
36166
|
+
{
|
|
36167
|
+
name: "pg_seq_scan_tables",
|
|
36168
|
+
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.",
|
|
36169
|
+
annotations: {
|
|
36170
|
+
title: "Find tables with heavy sequential scans",
|
|
36171
|
+
readOnlyHint: true,
|
|
36172
|
+
destructiveHint: false,
|
|
36173
|
+
idempotentHint: true,
|
|
36174
|
+
openWorldHint: true
|
|
36175
|
+
},
|
|
36176
|
+
inputSchema: external_exports3.object({
|
|
36177
|
+
schema: identSchema2.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
36178
|
+
minSize: external_exports3.number().int().min(0).default(1e3).describe("Minimum live tuple count to include (default 1000, filters out tiny/empty tables)."),
|
|
36179
|
+
limit: external_exports3.number().int().min(1).max(100).default(20).describe("Max rows to return (default 20).")
|
|
36180
|
+
}),
|
|
36181
|
+
handler: async (input) => {
|
|
36182
|
+
const { schema, minSize, limit } = input;
|
|
36183
|
+
const schemaFilter = schema ? "AND schemaname = $3" : "AND schemaname NOT IN ('pg_catalog', 'information_schema') AND schemaname NOT LIKE 'pg_%'";
|
|
36184
|
+
const params = [minSize, limit];
|
|
36185
|
+
if (schema) params.push(schema);
|
|
36186
|
+
return runInternal(
|
|
36187
|
+
`SELECT
|
|
36188
|
+
schemaname AS schema,
|
|
36189
|
+
relname AS "table",
|
|
36190
|
+
seq_scan::text AS seq_scans,
|
|
36191
|
+
COALESCE(idx_scan, 0)::text AS idx_scans,
|
|
36192
|
+
n_live_tup::text AS live_tuples,
|
|
36193
|
+
seq_tup_read::text AS seq_tup_read,
|
|
36194
|
+
CASE
|
|
36195
|
+
WHEN COALESCE(idx_scan, 0) = 0 AND seq_scan > 0 THEN NULL
|
|
36196
|
+
WHEN COALESCE(idx_scan, 0) = 0 THEN 0
|
|
36197
|
+
ELSE (seq_scan::numeric / NULLIF(idx_scan, 0))::numeric(10, 2)::float8
|
|
36198
|
+
END AS ratio
|
|
36199
|
+
FROM pg_catalog.pg_stat_user_tables
|
|
36200
|
+
WHERE n_live_tup >= $1
|
|
36201
|
+
${schemaFilter}
|
|
36202
|
+
ORDER BY seq_scan DESC NULLS LAST
|
|
36203
|
+
LIMIT $2`,
|
|
36204
|
+
params
|
|
36205
|
+
);
|
|
36206
|
+
}
|
|
36207
|
+
},
|
|
36208
|
+
{
|
|
36209
|
+
name: "pg_unused_indexes",
|
|
36210
|
+
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.",
|
|
36211
|
+
annotations: {
|
|
36212
|
+
title: "Find unused indexes",
|
|
36213
|
+
readOnlyHint: true,
|
|
36214
|
+
destructiveHint: false,
|
|
36215
|
+
idempotentHint: true,
|
|
36216
|
+
openWorldHint: true
|
|
36217
|
+
},
|
|
36218
|
+
inputSchema: external_exports3.object({
|
|
36219
|
+
schema: identSchema2.optional().describe("Limit to one schema. If omitted, all user schemas are included."),
|
|
36220
|
+
maxScans: external_exports3.number().int().min(0).default(10).describe("Include indexes with scan count <= this (default 10). Use 0 for 'never scanned'."),
|
|
36221
|
+
limit: external_exports3.number().int().min(1).max(200).default(50).describe("Max rows to return (default 50).")
|
|
36222
|
+
}),
|
|
36223
|
+
handler: async (input) => {
|
|
36224
|
+
const { schema, maxScans, limit } = input;
|
|
36225
|
+
const schemaFilter = schema ? "AND s.schemaname = $3" : "AND s.schemaname NOT IN ('pg_catalog', 'information_schema') AND s.schemaname NOT LIKE 'pg_%'";
|
|
36226
|
+
const params = [maxScans, limit];
|
|
36227
|
+
if (schema) params.push(schema);
|
|
36228
|
+
return runInternal(
|
|
36229
|
+
`SELECT
|
|
36230
|
+
s.schemaname AS schema,
|
|
36231
|
+
s.relname AS "table",
|
|
36232
|
+
s.indexrelname AS "index",
|
|
36233
|
+
s.idx_scan::text AS scans,
|
|
36234
|
+
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size_pretty,
|
|
36235
|
+
pg_relation_size(s.indexrelid)::text AS size_bytes,
|
|
36236
|
+
pg_catalog.pg_get_indexdef(s.indexrelid) AS definition
|
|
36237
|
+
FROM pg_catalog.pg_stat_user_indexes s
|
|
36238
|
+
JOIN pg_catalog.pg_index i ON i.indexrelid = s.indexrelid
|
|
36239
|
+
WHERE s.idx_scan <= $1
|
|
36240
|
+
AND NOT i.indisunique
|
|
36241
|
+
AND NOT i.indisprimary
|
|
36242
|
+
${schemaFilter}
|
|
36243
|
+
ORDER BY pg_relation_size(s.indexrelid) DESC
|
|
36244
|
+
LIMIT $2`,
|
|
36245
|
+
params
|
|
36246
|
+
);
|
|
36247
|
+
}
|
|
35678
36248
|
}
|
|
35679
36249
|
];
|
|
36250
|
+
function compareVersions(a, b) {
|
|
36251
|
+
const parse3 = (v) => v.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
36252
|
+
const aa = parse3(a);
|
|
36253
|
+
const bb = parse3(b);
|
|
36254
|
+
const len = Math.max(aa.length, bb.length);
|
|
36255
|
+
for (let i = 0; i < len; i++) {
|
|
36256
|
+
const diff = (aa[i] ?? 0) - (bb[i] ?? 0);
|
|
36257
|
+
if (diff !== 0) return diff;
|
|
36258
|
+
}
|
|
36259
|
+
return 0;
|
|
36260
|
+
}
|
|
35680
36261
|
|
|
35681
36262
|
// src/index.ts
|
|
35682
|
-
var version2 = true ? "0.
|
|
36263
|
+
var version2 = true ? "0.3.1" : (await null).createRequire(import.meta.url)("../package.json").version;
|
|
35683
36264
|
var subcommand = process.argv[2];
|
|
35684
36265
|
if (subcommand === "version" || subcommand === "--version") {
|
|
35685
36266
|
console.log(version2);
|
|
35686
36267
|
process.exit(0);
|
|
35687
36268
|
}
|
|
35688
|
-
var allTools = [...queryTools, ...schemaTools, ...explainTools, ...healthTools];
|
|
36269
|
+
var allTools = [...queryTools, ...schemaTools, ...explainTools, ...healthTools, ...statsTools, ...adminTools];
|
|
35689
36270
|
var server = new McpServer({
|
|
35690
36271
|
name: "@yawlabs/postgres-mcp",
|
|
35691
36272
|
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.1",
|
|
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>",
|
|
@@ -31,8 +31,9 @@
|
|
|
31
31
|
"build": "tsc && node build.mjs",
|
|
32
32
|
"dev": "tsc --watch",
|
|
33
33
|
"start": "node dist/index.js",
|
|
34
|
-
"test": "npm run build && node
|
|
34
|
+
"test": "npm run build && node scripts/run-tests.mjs dist",
|
|
35
35
|
"test:ci": "npm run test",
|
|
36
|
+
"test:integration": "npm run build && node scripts/run-tests.mjs dist --integration",
|
|
36
37
|
"lint": "biome check src/",
|
|
37
38
|
"lint:fix": "biome check --write src/",
|
|
38
39
|
"prepublishOnly": "npm run build"
|