postgres-mcp-safe 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +135 -2
- package/bin/postgres-mcp-safe.js +33 -0
- package/package.json +34 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sandesh Poudel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,136 @@
|
|
|
1
|
-
#
|
|
1
|
+
# postgres-mcp-safe
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A drop-in replacement for the deprecated `@modelcontextprotocol/server-postgres`, but actually read-only.
|
|
4
|
+
|
|
5
|
+
Lets AI tools (Claude, Cursor, Zed, ...) query a Postgres database, read its schema and explain queries. Swap the
|
|
6
|
+
package name in your MCP config and keep everything else.
|
|
7
|
+
|
|
8
|
+
> Status: pre-release. Not on npm or Docker Hub yet.
|
|
9
|
+
|
|
10
|
+
## Migrating from server-postgres
|
|
11
|
+
|
|
12
|
+
```diff
|
|
13
|
+
{
|
|
14
|
+
"mcpServers": {
|
|
15
|
+
"postgres": {
|
|
16
|
+
"command": "npx",
|
|
17
|
+
- "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"]
|
|
18
|
+
+ "args": ["-y", "postgres-mcp-safe", "postgresql://localhost/mydb"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Docker: `docker run -i --rm ghcr.io/poudel0/postgres-mcp-safe postgresql://host.docker.internal:5432/mydb`.
|
|
25
|
+
Binaries for Linux, macOS and Windows are on the [releases page](https://github.com/Poudel0/postgres-mcp-safe/releases).
|
|
26
|
+
|
|
27
|
+
**One catch:** if your URL logs in as a superuser (like Docker's default `postgres` user), every call is refused
|
|
28
|
+
until you either [create a read-only role](#use-a-read-only-role-too) (recommended) or add `--allow-superuser`
|
|
29
|
+
before the URL. A superuser can get around a read-only transaction, so we won't pretend otherwise.
|
|
30
|
+
|
|
31
|
+
The old server's `query` tool and `postgres://.../<table>/schema` resources work the same. See
|
|
32
|
+
[Differences](#differences-from-server-postgres) for the small, deliberate changes.
|
|
33
|
+
|
|
34
|
+
## Why
|
|
35
|
+
|
|
36
|
+
The old server's "read-only" mode can be broken with `COMMIT; DROP TABLE ...`
|
|
37
|
+
([Datadog Security Labs, 2025-08-21](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/)).
|
|
38
|
+
A fix was merged upstream but never published, and the package is deprecated with no official successor.
|
|
39
|
+
|
|
40
|
+
## Flags
|
|
41
|
+
|
|
42
|
+
Flags go **before** the URL.
|
|
43
|
+
|
|
44
|
+
| Flag | Default | |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `--timeout` | `30s` | Time limit per call. |
|
|
47
|
+
| `--max-rows` | `1000` | Rows returned per call. Extra rows are cut off with a note. |
|
|
48
|
+
| `--max-bytes` | `262144` | JSON bytes returned per call, and the max size of a single row. |
|
|
49
|
+
| `--allow-explain-analyze` | off | Lets `explain` run `EXPLAIN ANALYZE`, which executes the query. |
|
|
50
|
+
| `--allow-superuser` | off | Allows a superuser role, or a member of one. Read-only is then **not** guaranteed. |
|
|
51
|
+
|
|
52
|
+
Limits can't be turned off; `0` is an error. The URL can also be a keyword DSN (`host=db dbname=app`), and the
|
|
53
|
+
standard `PG*` environment variables (`PGPASSWORD`, ...) are read as libpq would.
|
|
54
|
+
|
|
55
|
+
## Tools
|
|
56
|
+
|
|
57
|
+
| Tool | Input | |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `query` | `sql` | One read-only statement. Returns a JSON array of rows. |
|
|
60
|
+
| `list_tables` | `schema?` | Tables and views with schema and type. |
|
|
61
|
+
| `describe_table` | `table`, `schema?` (`public`) | Columns, constraints and indexes. |
|
|
62
|
+
| `explain` | `sql`, `analyze?` | Query plan. `analyze` needs `--allow-explain-analyze`. |
|
|
63
|
+
|
|
64
|
+
## How read-only is enforced
|
|
65
|
+
|
|
66
|
+
Postgres enforces it, not a SQL parser. Every call:
|
|
67
|
+
|
|
68
|
+
1. Opens a **fresh connection** (no session state survives a call) and starts a `READ ONLY` transaction.
|
|
69
|
+
2. Sends your SQL as a single protocol-level *Parse* message. Postgres rejects more than one statement there, so
|
|
70
|
+
`COMMIT; ...` fails before anything runs.
|
|
71
|
+
3. Runs it only if Postgres says it returns rows. `COMMIT`, `SET`, `DO`, `COPY`, `CALL`, `PREPARE` and friends are
|
|
72
|
+
rejected without running.
|
|
73
|
+
4. Applies `statement_timeout`, a client deadline, and row and byte caps.
|
|
74
|
+
5. Closes the connection, which ends the transaction.
|
|
75
|
+
|
|
76
|
+
The [bypass suite](bypass_test.go) runs every known payload against a real database and checks a canary table after
|
|
77
|
+
each one:
|
|
78
|
+
|
|
79
|
+
| Payload | Result |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `COMMIT; DROP SCHEMA public CASCADE;` | Rejected: multiple statements |
|
|
82
|
+
| `COMMIT` / `SET TRANSACTION READ WRITE` / `DO $$...$$` / `CALL p()` | Rejected: returns no rows |
|
|
83
|
+
| `COPY (SELECT 1) TO PROGRAM '...'` (as superuser) | Rejected: returns no rows |
|
|
84
|
+
| `DELETE ... RETURNING *` / `WITH d AS (DELETE ...) SELECT ...` | Read-only transaction error |
|
|
85
|
+
| `SELECT nextval('s')` | Read-only transaction error |
|
|
86
|
+
| `SELECT set_config('transaction_read_only','off',true)` | Error |
|
|
87
|
+
| `SELECT pg_sleep(600)`, even after `set_config('statement_timeout','0')` | Cancelled at `--timeout` |
|
|
88
|
+
| `SELECT pg_advisory_lock(1)` | Released when the call ends |
|
|
89
|
+
| `SELECT generate_series(1,10000000)` / `repeat('x', 10000000)` | Capped / rejected, memory stays bounded |
|
|
90
|
+
| `explain` with `ANALYZE SELECT ...` | Syntax error: can't smuggle `ANALYZE` |
|
|
91
|
+
|
|
92
|
+
Found a bypass? Open an issue. It becomes a test row before the fix.
|
|
93
|
+
|
|
94
|
+
## Use a read-only role too
|
|
95
|
+
|
|
96
|
+
This server makes each call read-only **at the transaction level**. Some things are out of a transaction's reach,
|
|
97
|
+
and only the database role stops them:
|
|
98
|
+
|
|
99
|
+
- `dblink_exec(...)` opens a *separate* session that isn't read-only.
|
|
100
|
+
- `pg_terminate_backend(...)` kills other connections.
|
|
101
|
+
- A superuser can read server files (`pg_read_file`, `pg_ls_dir`): read-only stops writes, not reads.
|
|
102
|
+
- A superuser can **write** server files with `lo_export`, even inside a read-only transaction.
|
|
103
|
+
|
|
104
|
+
So connect as a role that can only read:
|
|
105
|
+
|
|
106
|
+
```sql
|
|
107
|
+
CREATE ROLE mcp_ro LOGIN PASSWORD '...';
|
|
108
|
+
GRANT pg_read_all_data TO mcp_ro; -- PG 14+; or GRANT SELECT on what it needs
|
|
109
|
+
ALTER ROLE mcp_ro SET default_transaction_read_only = on;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Because of the last two, the server refuses superuser roles (and members of them) unless you pass `--allow-superuser`.
|
|
113
|
+
|
|
114
|
+
## Differences from server-postgres
|
|
115
|
+
|
|
116
|
+
1. Errors in a tool call come back as a tool result with `isError: true` and the Postgres message and SQLSTATE, so
|
|
117
|
+
the model can fix its SQL. The old server sent a JSON-RPC error.
|
|
118
|
+
2. `resources/read` only shows columns of the `public` table, in column order. The old one mixed in columns from
|
|
119
|
+
same-named tables in other schemas.
|
|
120
|
+
3. Percent-encoded table names in resource URIs are decoded (the old server never matched them).
|
|
121
|
+
4. Values are what Postgres prints, typed where it's clear: booleans, numbers (including `bigint` and `numeric`)
|
|
122
|
+
and `json` are JSON values; everything else is a string such as `"2026-01-02 03:04:05+00"`, `"\\x0102"` or
|
|
123
|
+
`"{x,y}"`. `NaN` is `"NaN"` (the old server gave `null`).
|
|
124
|
+
5. Statements that return no rows (`SET`, `DO`, `COPY`, ...) are rejected. The old server ran them and returned `[]`.
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
docker run -d --name pg-mcp-test -e POSTGRES_PASSWORD=postgres -p 127.0.0.1:5434:5432 postgres:17-alpine
|
|
130
|
+
export DATABASE_URL='postgresql://postgres:postgres@127.0.0.1:5434/postgres?sslmode=disable'
|
|
131
|
+
make test # all tests, including the bypass suite
|
|
132
|
+
make golden # re-record the old server's output (needs npx)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`DATABASE_URL` must be a superuser: the fixture resets the `public` schema and creates the `mcp_ro` role. Use a
|
|
136
|
+
throwaway database.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// Runs the Go binary from the platform package npm picked via optionalDependencies.
|
|
5
|
+
const { spawn } = require("node:child_process");
|
|
6
|
+
|
|
7
|
+
const pkg = `postgres-mcp-safe-${process.platform}-${process.arch}`;
|
|
8
|
+
const exe = process.platform === "win32" ? "postgres-mcp-safe.exe" : "postgres-mcp-safe";
|
|
9
|
+
|
|
10
|
+
let bin;
|
|
11
|
+
try {
|
|
12
|
+
bin = require.resolve(`${pkg}/bin/${exe}`);
|
|
13
|
+
} catch {
|
|
14
|
+
console.error(
|
|
15
|
+
`postgres-mcp-safe: no binary for ${process.platform}-${process.arch} (${pkg} is not installed).\n` +
|
|
16
|
+
"Supported: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64. " +
|
|
17
|
+
"If you installed with --no-optional, reinstall without it.",
|
|
18
|
+
);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const child = spawn(bin, process.argv.slice(2), { stdio: "inherit" });
|
|
23
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
24
|
+
process.on(sig, () => child.kill(sig));
|
|
25
|
+
}
|
|
26
|
+
child.on("error", (err) => {
|
|
27
|
+
console.error(`postgres-mcp-safe: ${err.message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
30
|
+
child.on("exit", (code, signal) => {
|
|
31
|
+
if (signal) process.kill(process.pid, signal);
|
|
32
|
+
else process.exit(code ?? 1);
|
|
33
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "postgres-mcp-safe",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Drop-in replacement for @modelcontextprotocol/server-postgres that is actually read-only.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"postgres",
|
|
9
|
+
"postgresql",
|
|
10
|
+
"read-only",
|
|
11
|
+
"server-postgres"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/Poudel0/postgres-mcp-safe",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/Poudel0/postgres-mcp-safe.git"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"mcpName": "io.github.Poudel0/postgres-mcp-safe",
|
|
20
|
+
"bin": {
|
|
21
|
+
"postgres-mcp-safe": "bin/postgres-mcp-safe.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"optionalDependencies": {
|
|
30
|
+
"postgres-mcp-safe-linux-arm64": "0.1.1",
|
|
31
|
+
"postgres-mcp-safe-linux-x64": "0.1.1",
|
|
32
|
+
"postgres-mcp-safe-darwin-x64": "0.1.1",
|
|
33
|
+
"postgres-mcp-safe-darwin-arm64": "0.1.1",
|
|
34
|
+
"postgres-mcp-safe-win32-x64": "0.1.1"
|
|
35
|
+
}
|
|
36
|
+
}
|