@yawlabs/postgres-mcp 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-05-14
11
+
12
+ ### Added
13
+ - New `pg_readonly` tool. Always runs inside `BEGIN READ ONLY` regardless of
14
+ `ALLOW_WRITES`, so postgres itself rejects any write attempt. The point is
15
+ to give hosts that gate tools individually (Claude Code permissions,
16
+ mcp.hosting per-tool toggles) a stable always-safe target to auto-allow,
17
+ independent of how the server is configured. Same input shape as `pg_query`
18
+ (`sql` + `params`).
19
+ - New "Configuring access" README section walking through the recommended
20
+ least-privileged-role posture: `CREATE ROLE mcp_reader ... GRANT
21
+ pg_read_all_data` for read-only agents, and a `mcp_writer` example with
22
+ table-level grants for scoped writes. The role is the primary access
23
+ control; `ALLOW_WRITES` is positioned as secondary belt-and-braces.
24
+
25
+ ### Changed
26
+ - `pg_query` description leads with role-based access control. `ALLOW_WRITES`
27
+ is now framed as a secondary gate, with the role in `DATABASE_URL` as the
28
+ authoritative one. No behavior change.
29
+ - README "Why this one?" read-only bullet expanded: `pg_query` continues to
30
+ default to read-only via `BEGIN READ ONLY`, and `pg_readonly` is the new
31
+ unconditional read tool. Configuration table and troubleshooting entry
32
+ for `ALLOW_WRITES` updated to point at the Configuring access section.
33
+
10
34
  ## [0.5.3] - 2026-05-14
11
35
 
12
36
  ### Security
package/README.md CHANGED
@@ -7,6 +7,10 @@
7
7
 
8
8
  Built and maintained by [Yaw Labs](https://yaw.sh).
9
9
 
10
+ [![Add to mcp.hosting](https://mcp.hosting/install-button.svg)](https://mcp.hosting/install?name=Postgres&command=npx&args=-y%2C%40yawlabs%2Fpostgres-mcp&description=Query%20PostgreSQL%20-%20schema%20introspection%2C%20EXPLAIN%20plans%2C%20health%20diagnostics%2C%20read-only%20by%20default&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fpostgres-mcp)
11
+
12
+ One click adds this to your [mcp.hosting](https://mcp.hosting) account so it syncs to every MCP client you use. Or install manually below.
13
+
10
14
  ## Backstory
11
15
 
12
16
  Anthropic's reference Postgres MCP server, `@modelcontextprotocol/server-postgres`, was [archived in May 2025](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) and [marked deprecated on npm](https://www.npmjs.com/package/@modelcontextprotocol/server-postgres) in July 2025. Anthropic has not shipped a replacement. Despite the deprecation, the last published version (v0.6.2) is still pulled ~20,000 times per week - a lot of agents are pointed at an unmaintained package.
@@ -23,7 +27,8 @@ None of them position themselves as a general-purpose daily driver you'd hand to
23
27
 
24
28
  ## Why this one?
25
29
 
26
- - **Read-only by default** - user SQL runs in a `BEGIN READ ONLY` transaction, so postgres itself (not string parsing) blocks writes. Opt in with `ALLOW_WRITES=1`.
30
+ - **Read-only by default, with an unconditional read-only tool too** - `pg_query` runs user SQL in a `BEGIN READ ONLY` transaction, so postgres itself (not string parsing) blocks writes; opt in to writes with `ALLOW_WRITES=1`. `pg_readonly` is a separate tool that stays read-only regardless of `ALLOW_WRITES`, so hosts that gate tools individually (Claude Code permissions, mcp.hosting) can safely auto-allow it.
31
+ - **Role-based access as the primary control** - the recommended posture is to use a least-privileged postgres role in `DATABASE_URL` (e.g. one with `GRANT pg_read_all_data`); postgres itself then enforces the boundary, no env var needed. See [Configuring access](#configuring-access).
27
32
  - **Extended query protocol for all user SQL** - `pg_query` sends user input with `queryMode: 'extended'`, which restricts each request to a single statement. This closes the [stacked-query injection class](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/) (`COMMIT; DROP SCHEMA x CASCADE;`) that defeated the reference server's `BEGIN READ ONLY` wrapper. Integration test asserts the rejection.
28
33
  - **Parameterized queries** - `pg_query` takes a `params` array for `$1`, `$2`, etc. No string-interpolated SQL in our code path.
29
34
  - **Written from scratch, actively maintained** - not a fork of the deprecated code. Unit + integration tests (`npm test`, `npm run test:integration`) run against a real Postgres; releases cut via `release.sh`.
@@ -90,6 +95,51 @@ Read-only is the default. If you want the agent to be able to `INSERT`, `UPDATE`
90
95
 
91
96
  Prefer scoping this to dev/test databases - for production, leave writes off and use migration tools out-of-band.
92
97
 
98
+ ## Configuring access
99
+
100
+ The role in `DATABASE_URL` is the primary access control. Postgres has had a battle-tested permission system for 30 years; lean on it instead of relying on `ALLOW_WRITES` alone. A least-privileged role makes writes server-rejected no matter what tools or env vars are configured.
101
+
102
+ **Read-only agent (recommended default):**
103
+
104
+ ```sql
105
+ CREATE ROLE mcp_reader LOGIN PASSWORD 'change-me';
106
+ GRANT CONNECT ON DATABASE your_db TO mcp_reader;
107
+ GRANT USAGE ON SCHEMA public TO mcp_reader;
108
+ GRANT pg_read_all_data TO mcp_reader;
109
+ ```
110
+
111
+ Point `DATABASE_URL` at `mcp_reader`. Postgres rejects every write, every DDL, every privilege change - regardless of `ALLOW_WRITES`. No app-level guard to bypass; the database is the boundary.
112
+
113
+ **Scoped write agent (dev/test or narrow production use):**
114
+
115
+ ```sql
116
+ CREATE ROLE mcp_writer LOGIN PASSWORD 'change-me';
117
+ GRANT CONNECT ON DATABASE your_db TO mcp_writer;
118
+ GRANT USAGE ON SCHEMA public TO mcp_writer;
119
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO mcp_writer;
120
+ GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO mcp_writer;
121
+ -- DDL not granted -- the agent can change data but not schema.
122
+ ```
123
+
124
+ Set `ALLOW_WRITES=1` so `pg_query` will issue writes, and rely on the role to keep the agent away from DDL and other schemas.
125
+
126
+ **Per-tool gating in the host:**
127
+
128
+ Tools split cleanly across two authority classes:
129
+
130
+ - **Auto-allow:** `pg_readonly` (server-side `BEGIN READ ONLY`, unconditional), plus the introspection tools (`pg_list_*`, `pg_describe_table`, `pg_search_columns`, `pg_explain` without ANALYZE-of-write, `pg_health`, `pg_inspect_locks`, `pg_table_bloat`, `pg_unused_indexes`, `pg_top_queries`, `pg_replication_status`, `pg_advisor`, `pg_table_privileges`, `pg_list_roles`).
131
+ - **Always prompt:** `pg_query` (can write when the role allows it), `pg_kill` (changes session state).
132
+
133
+ Claude Code's `permissions` block and mcp.hosting's per-tool toggle both honor this split.
134
+
135
+ **`ALLOW_WRITES` as defense-in-depth:**
136
+
137
+ `ALLOW_WRITES` is a secondary belt-and-braces gate. Useful when:
138
+ - You're on a managed database where creating a second role is awkward (some Supabase/Neon plans).
139
+ - You want a single role that can write, but want the MCP server to refuse writes anyway during normal operation.
140
+
141
+ Otherwise, configure the role and stop relying on `ALLOW_WRITES`.
142
+
93
143
  ## What can an agent do with this?
94
144
 
95
145
  Once connected, the agent picks tools automatically based on what you ask. A few single-tool examples:
@@ -111,7 +161,8 @@ The bigger leverage is multi-tool reasoning. A few real workflows:
111
161
 
112
162
  | Tool | Description |
113
163
  |------|-------------|
114
- | `pg_query` | Run a SQL query. Read-only by default; writes require `ALLOW_WRITES=1`. Supports parameterized queries via `params`. Result fields include `dataTypeName` (e.g. `int4`, `jsonb`) alongside `dataTypeID`. |
164
+ | `pg_readonly` | Run a SQL statement guaranteed read-only - always inside `BEGIN READ ONLY`, regardless of `ALLOW_WRITES`. The recommended tool for read access; safe for hosts to auto-allow. |
165
+ | `pg_query` | Run a SQL query. Writes gated by the role in `DATABASE_URL` first, `ALLOW_WRITES` second. Supports parameterized queries via `params`. Result fields include `dataTypeName` (e.g. `int4`, `jsonb`) alongside `dataTypeID`. |
115
166
  | `pg_list_schemas` | List non-system schemas. |
116
167
  | `pg_list_tables` | List tables (and optionally views) in a schema with estimated row counts. Paginated via `limit`/`offset`. |
117
168
  | `pg_describe_table` | Kind, columns, PK, outgoing FKs, incoming FKs (`referenced_by`), CHECK / UNIQUE / EXCLUDE constraints, indexes, and partition parent/children for a relation. |
@@ -139,7 +190,7 @@ All env vars are read from the MCP server's environment:
139
190
  | Variable | Default | Purpose |
140
191
  |----------|---------|---------|
141
192
  | `DATABASE_URL` | (required) | PostgreSQL connection string. |
142
- | `ALLOW_WRITES` | unset | Set to `1` or `true` to allow DML/DDL via `pg_query` and `pg_explain` ANALYZE of writes. |
193
+ | `ALLOW_WRITES` | unset | Secondary write gate for `pg_query` and `pg_explain` ANALYZE-of-writes. Set to `1` or `true` to lift the `BEGIN READ ONLY` wrapper. The role in `DATABASE_URL` is the primary control - see [Configuring access](#configuring-access). Does not affect `pg_readonly`, which is unconditional. |
143
194
  | `POSTGRES_STATEMENT_TIMEOUT_MS` | `30000` | Per-statement timeout. |
144
195
  | `POSTGRES_CONNECTION_TIMEOUT_MS` | `10000` | TCP connect timeout. Without this, a dead host hangs until the OS gives up (~2 minutes). |
145
196
  | `POSTGRES_MAX_ROWS` | `1000` | Cap on rows returned by `pg_query`. |
@@ -179,7 +230,7 @@ This disables certificate chain verification only -- the TCP connection is still
179
230
 
180
231
  **`canceling statement due to statement timeout`** - A single query exceeded `POSTGRES_STATEMENT_TIMEOUT_MS` (default 30s). Increase it, narrow the query with `WHERE`, or add an index. This is working as designed -- the timeout exists so a runaway query cannot hang the agent.
181
232
 
182
- **`Write blocked: this server is in read-only mode`** - You asked the agent to write but `ALLOW_WRITES` is not set. Add `ALLOW_WRITES=1` to the `env` block of `.mcp.json` and restart your MCP client. Only do this for dev/test DBs.
233
+ **`Write blocked: this server is in read-only mode`** - You asked the agent to write via `pg_query` but `ALLOW_WRITES` is not set. Either add `ALLOW_WRITES=1` to the `env` block of `.mcp.json` and restart your MCP client (dev/test DBs), or - cleaner for production - use a role with `INSERT/UPDATE/DELETE` grants in `DATABASE_URL` and keep `ALLOW_WRITES` unset. See [Configuring access](#configuring-access). Note that `pg_readonly` always rejects writes; if you want writes, the call has to go through `pg_query`.
183
234
 
184
235
  **Connection pool exhaustion with PgBouncer transaction mode or pglite-socket** - These backends don't support concurrent queries on a single connection. Set `POSTGRES_POOL_MAX=1` in the env block.
185
236
 
package/dist/index.js CHANGED
@@ -36904,13 +36904,32 @@ var healthTools = [
36904
36904
 
36905
36905
  // src/tools/query.ts
36906
36906
  var queryTools = [
36907
+ {
36908
+ name: "pg_readonly",
36909
+ description: "Run a SQL statement guaranteed read-only. Always executes inside a `BEGIN READ ONLY` transaction regardless of `ALLOW_WRITES`, so postgres itself rejects any write attempt. Use this whenever the goal is to read - SELECT, EXPLAIN, SHOW, VALUES, WITH ... SELECT, etc. Hosts that gate tools individually (Claude Code permissions, mcp.hosting) can safely auto-allow this one. Use `params` for parameterized queries to avoid SQL injection. Params can be strings, numbers, booleans, null, arrays (for postgres arrays / ANY), or objects (for json/jsonb columns). Large result sets are truncated to POSTGRES_MAX_ROWS (default 1000) with a `truncated: true` flag.",
36910
+ annotations: {
36911
+ title: "Run read-only SQL",
36912
+ readOnlyHint: true,
36913
+ destructiveHint: false,
36914
+ idempotentHint: true,
36915
+ openWorldHint: true
36916
+ },
36917
+ inputSchema: external_exports.object({
36918
+ sql: external_exports.string().min(1).max(1e6).describe("The SQL statement to execute. Hard cap of 1 MB."),
36919
+ params: external_exports.array(paramValue).optional().describe("Positional parameters referenced as $1, $2, ... in the SQL.")
36920
+ }),
36921
+ handler: async (input) => {
36922
+ const { sql, params } = input;
36923
+ return runReadOnly(sql, params ?? []);
36924
+ }
36925
+ },
36907
36926
  {
36908
36927
  name: "pg_query",
36909
- 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.",
36928
+ description: "Run a SQL query against the configured PostgreSQL database. Writes are gated by the role in `DATABASE_URL` first and `ALLOW_WRITES` second: a role created with `GRANT pg_read_all_data` makes writes server-rejected regardless of `ALLOW_WRITES`, and is the recommended way to scope agent access. `ALLOW_WRITES=1` is a secondary belt-and-braces gate - useful for managed databases where creating a second role is awkward. For read-only access where you want the guarantee in the tool name, prefer `pg_readonly`. Use `params` for parameterized queries to avoid SQL injection. Params can be strings, numbers, booleans, null, arrays (for postgres arrays / ANY), or objects (for json/jsonb columns). Dates and UUIDs can be passed as ISO strings. Large result sets are truncated to POSTGRES_MAX_ROWS (default 1000) with a `truncated: true` flag.",
36910
36929
  annotations: {
36911
36930
  title: "Run SQL query",
36912
36931
  readOnlyHint: false,
36913
- // conditionally destructive based on ALLOW_WRITES
36932
+ // conditionally destructive based on role + ALLOW_WRITES
36914
36933
  destructiveHint: true,
36915
36934
  idempotentHint: false,
36916
36935
  openWorldHint: true
@@ -37510,7 +37529,7 @@ function compareVersions(a, b) {
37510
37529
  }
37511
37530
 
37512
37531
  // src/index.ts
37513
- var version2 = true ? "0.5.4" : (await null).createRequire(import.meta.url)("../package.json").version;
37532
+ var version2 = true ? "0.6.0" : (await null).createRequire(import.meta.url)("../package.json").version;
37514
37533
  var subcommand = process.argv[2];
37515
37534
  if (subcommand === "version" || subcommand === "--version") {
37516
37535
  console.log(version2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/postgres-mcp",
3
- "version": "0.5.4",
3
+ "version": "0.6.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>",