@yawlabs/postgres-mcp 0.1.0 → 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.
Files changed (3) hide show
  1. package/README.md +62 -2
  2. package/dist/index.js +609 -30
  3. 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
- SSL is handled by the `pg` driver based on the connection string use `?sslmode=require` (or equivalent) in `DATABASE_URL` for cloud-hosted databases.
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
- idleTimeoutMillis: 1e4
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(external_exports3.union([external_exports3.string(), external_exports3.number(), external_exports3.boolean(), external_exports3.null()])).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
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
- handler: async () => {
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 10`
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(external_exports3.union([external_exports3.string(), external_exports3.number(), external_exports3.boolean(), external_exports3.null()])).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
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 IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
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: external_exports3.string().default("public").describe("Schema name (defaults to 'public')."),
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
- [schema]
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: external_exports3.string().default("public").describe("Schema name (defaults to 'public')."),
35586
- table: external_exports3.string().describe("Table name.")
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.1.0" : (await null).createRequire(import.meta.url)("../package.json").version;
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.1.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"