{
  "markdown": "# postgres-mcp-hardened\n\n> ### 🚧 Version 0.1.10 — the rest of an outside review, and a fix that was too blunt\n>\n> Published: binaries for five platforms with checksums, Sigstore signatures and build provenance;\n> `.mcpb` bundles for one-click install; an image on `ghcr.io` for amd64 and arm64; a package on npm;\n> and an entry in the official MCP registry.\n>\n> **0.1.8 and 0.1.9 closed six bypasses that four independent reviewers found in 0.1.7, none of them\n> found by us.** Two days of our own adversarial work had come back mostly clean the day before.\n> Passing the tests you thought to write is not the same as looking. The one that mattered most needs\n> no privileges at all: with a column redacted, a join on it through `USING` answered whether a given\n> value was present, which is a complete equality oracle against the least-privilege reader this\n> project tells you to configure.\n>\n> **0.1.10 closes the three findings that were left open.** With `MCP_ALLOW_SCHEMAS=public`,\n> `SELECT * FROM secret.salaries` was refused while `describe_table` handed over every column, type\n> and default of that same table. An ordinary connection string carrying no `sslmode` used the\n> driver's `prefer`, which sends everything in the clear whenever the server declines TLS, and anyone\n> on the wire can make it decline. And `MCP_SSLROOTCERT` added a private certificate authority to 242\n> public ones instead of replacing them, so an operator who believed they had pinned trust to their\n> own issuer had not.\n>\n> **The first version of that TLS fix was wrong in a way worth reading about.** It asked \"is this\n> loopback\" and demanded TLS from everything else, which took down six PostgreSQL version jobs, the\n> conformance run, the container check and the adversarial corpus in a single push. All of them reach\n> the database the way ordinary deployments do: `postgres://user:pass@postgres:5432/db`, a service\n> name on a private network. Docker Compose and Kubernetes are not the public internet, and a server\n> that demands TLS from a container on a bridge network is a server people switch off entirely. The\n> question is now whether somebody untrusted can sit on the wire, not whether the address is loopback,\n> and a real socket to a PostgreSQL reachable only by service name is in the test suite, because a\n> rule about networks should be tested over a network.\n>\n> A resource limit is documented and **not** solved, in [`THREAT_MODEL.md`](THREAT_MODEL.md): 49\n> bytes of SQL make PostgreSQL fold a constant into 5.9 GB of backend memory during planning, and a\n> five second `statement_timeout` does not stop it. The obvious shapes are refused; the general\n> problem is upstream of anything this server can do.\n>\n> Every change here was reproduced against a running server before being fixed, and each is in\n> [`CHANGELOG.md`](CHANGELOG.md) with the query.\n>\n> Everything here is 0.1.x because nobody outside this project has run it against their own data.\n\n**The official Postgres MCP server was deprecated in 2024 and still gets 391k downloads a month. Its entire defence is one database-level read-only transaction — and that alone does not stop every write. This is a maintained Rust replacement with defence in depth.**\n\nA drop-in [Model Context Protocol](https://modelcontextprotocol.io) server that lets an AI agent query PostgreSQL — **read-only, enforced at the database level**, with real SQL validation, timeouts, cost limits, OAuth 2.1, and an audit trail. Speaks **Streamable HTTP** and stdio, and negotiates the MCP revision: `2026-07-28` (current, and the default since upstream released it on 2026-08-03), `2025-11-25`, and `2025-06-18` — what most shipping clients still speak today. A client asks for what it knows; it is not negotiated down.\n\n## Try to break it — one command, no database\n\nThe read-only guard has an offline mode. Hand it a statement and it says what it decided: no\ndatabase, no configuration, nothing installed permanently.\n\n```sh\nnpx postgres-mcp-hardened --validate \"/* comment */ DROP TABLE users\"\n# REJECT: non-read-only statement: Drop\n\nnpx postgres-mcp-hardened --validate \"SELECT 1; DROP TABLE users\"\n# REJECT: multiple statements are forbidden\n\nnpx postgres-mcp-hardened --validate \"WITH d AS (DELETE FROM t RETURNING *) SELECT * FROM d\"\n# REJECT: non-read-only statement: non-read-only query (CTE / SELECT INTO / FOR UPDATE)\n\nnpx postgres-mcp-hardened --validate \"SELECT * FROM orders WHERE id = 1\"\n# ALLOW\n```\n\n**If something that writes comes back `ALLOW`, that is the most valuable thing anyone can send us.**\nIt needs no working exploit and no write-up — one line of SQL and \"this should not be allowed\" is a\ncomplete report. Anything that gets past the guard goes through [`SECURITY.md`](SECURITY.md);\neverything else is an ordinary issue, and the bar for opening one is *this looks wrong to me*, not\n*I am certain*.\n\nThe fuzzer is deterministic and prints its seed, so whatever it finds reproduces on a machine that\nhas never seen yours — a million mutations take about a minute:\n\n```sh\nnpx postgres-mcp-hardened --fuzz 1000000\n# fuzz: 1000000 iterations, seed 1592594996, slowest validation 8 ms\n# RESULT: 0 invariant violations\n```\n\nFor the whole thing against a real database, `docker compose -f examples/docker-compose.yml up -d`\nbrings up PostgreSQL with sample data and the server in front of it, connecting as a role that holds\n`SELECT` and nothing else.\n\nEvery bypass found so far lives in the `MUST_REJECT` corpus in `src/validate.rs` and runs on every\ncommit, recorded with what it cost rather than tidied away. Yours would join them.\n\n## Why\n\n`@modelcontextprotocol/server-postgres` is **deprecated on npm** (last publish December 2024) and\nstill sees **475,790 downloads in the 30 days to 9 August 2026**. Credit where it is due: its approach is not naive — it\nwraps each query in `BEGIN TRANSACTION READ ONLY` and always `ROLLBACK`s, which is a real defence\nand one this server now adopts as well.\n\nThe problem is that it is the *only* defence, and it is not complete:\n\n- **A read-only transaction does not block every write, and a rollback does not undo everything it\n  lets through.** Two separate facts, and the second is the one that matters.\n\n  `gin_clean_pending_list()` runs inside `SET TRANSACTION READ ONLY` and its work **survives the\n  rollback**: an index with 25 pending pages has 0 after the transaction is rolled back.\n  `pg_backup_start()` puts the session into backup state, survives `DISCARD ALL`, and with the\n  default `fast => false` waits for a spread checkpoint while forcing `full_page_writes` on, which\n  is a real cost on a busy server. `pg_import_system_collations()` also executes without raising\n  `SQLSTATE 25006`, but be careful how much weight you put on it: **that one IS undone by a\n  rollback**, so against a server that always rolls back it is a curiosity rather than a bypass.\n\n  Reproduce it, but read the two preconditions first, because without them you will see a zero or an\n  error and conclude we made this up. All three need superuser or ownership of the object. And the\n  import only restores collations that are *missing*, so something has to be removed first:\n\n  ```sql\n  -- as superuser, and note these are three separate transactions: a statement that errors\n  -- inside a block aborts the whole block, so they cannot be run as one.\n  DELETE FROM pg_collation WHERE oid IN (SELECT oid FROM pg_collation ORDER BY oid DESC LIMIT 200);\n\n  BEGIN READ ONLY;\n    DELETE FROM pg_collation WHERE collname LIKE 'zu%';  -- ERROR: cannot execute DELETE ...\n  ROLLBACK;\n\n  BEGIN READ ONLY;\n    SELECT pg_import_system_collations('pg_catalog');    -- 200, no error\n  COMMIT;                                                -- and now the rows are there\n  ```\n\n  Both are writes, both are inside a read-only transaction, and one is refused while the other is\n  not. That asymmetry is why this server does not treat the transaction as its only defence. It is\n  also why the *role* matters more than any of this: every example above needs privileges a\n  least-privilege reader does not have, and this server refuses to start as a network listener when\n  the role it was given can write. What it cannot control is which connection string somebody pastes\n  into a client config, and the usual answer is whichever one they already had.\n\n- **No statement timeout, no cost guard, no row limit** — one query can run until the server gives up.\n- **No authentication, no audit trail, no handling of prompt injection** through returned row data.\n- One source file of 143 lines, unmaintained since December 2024, no test suite.\n\nThis server keeps the rollback, adds AST validation in front of it, and adds the operational layers\nthe original never had.\n\n## `postgres-mcp-hardened` vs the archived original\n\n| | archived `server-postgres` | **postgres-mcp-hardened** |\n|---|---|---|\n| Read-only enforcement | `BEGIN TRANSACTION READ ONLY` + `ROLLBACK` — one layer, and PostgreSQL lets some writes through it | **AST validation (sqlparser)** *plus* the same read-only transaction and rollback, *plus* a denylist for functions that write despite it |\n| Multi-statement / `DROP` via CTE | reaches the database and is stopped only by the transaction | rejected by the parser, before it reaches the database |\n| Statement timeout | none | `statement_timeout` + `idle_in_transaction_session_timeout` enforced |\n| Runaway / expensive queries | run unbounded | **`EXPLAIN` cost guard** rejects them before execution |\n| Prompt injection via row data | raw output | wrapped `trusted=\"false\"` + delimiter escaping |\n| Error messages | leak schema (`relation X does not exist`) | structured, non-leaking, actionable |\n| Auth | none | **OAuth 2.1** (RS256 JWT, scope + audience + issuer) |\n| Audit | none | tamper-evident hash-chained log |\n| Schema as MCP resources | ✅ | ✅ — plus comments, primary and foreign keys |\n| Tests / CI | none | unit + end-to-end suites against live PostgreSQL, a deterministic fuzz harness, conformance driven by the official MCP SDK, clippy + `cargo audit` + container build on every push |\n| Transport | stdio / deprecated SSE | **Streamable HTTP** + stdio |\n| Maintained | ❌ deprecated since 2024 | ✅ |\n\n## Install\n\nFive ways in, in the order most people want them.\n\n**One click**, for a client that accepts `.mcpb` bundles: download\n`postgres-mcp-hardened-<your-platform>.mcpb` from the\n[latest release](https://github.com/Eszetael/postgres-mcp-hardened/releases/latest) and open it. The\nbundle asks for the connection string and stores it in the OS keychain rather than in a plain-text\nconfig file. Nothing to install, nothing to edit.\n\n**Through npm** — shortest, and the one your MCP client config can point at directly. There is no\nNode runtime involved at run time: the package is a launcher that fetches the native binary for your\nplatform and verifies its checksum before running it.\n\n```jsonc\n{\n  \"mcpServers\": {\n    \"postgres\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"postgres-mcp-hardened\", \"--stdio\"],\n      \"env\": { \"DATABASE_URL\": \"postgres://readonly_user:PASSWORD@localhost:5432/mydb\" }\n    }\n  }\n}\n```\n\nThe connection string goes in `env`, not in `args`, on purpose: arguments show up in `ps` output and\nin shell history on a shared machine, and a database password does not belong there.\n\n**A binary from the releases page** — one file, nothing to keep up to date, and the option to take if\nyour machine has no Node at all. (Not a *static* binary, as this page claimed until 0.1.7: the\n`-gnu` and macOS targets link the system C library like any other native program. There is simply\nnothing to install alongside it.) Every release carries builds for Linux, macOS and Windows\non x86-64 and arm64, each with a checksum and a signature; verifying them is the next section.\n\n**As a container**, if that is how you run things. The image is distroless and runs as a non-root\nuser, and the same signatures cover it as cover the binaries.\n\n```bash\ndocker run --rm -p 127.0.0.1:8080:8080 --memory=512m \\\n  -e DATABASE_URL=\"postgres://readonly_user:PASSWORD@db-host:5432/mydb\" \\\n  -e MCP_ADDR=0.0.0.0:8080 \\\n  -e MCP_BEARER_TOKEN=\"$(openssl rand -hex 32)\" \\\n  ghcr.io/eszetael/postgres-mcp-hardened:latest\n```\n\n`--memory` is not decoration. The server idles at 7.7 MB and a normal request costs single-digit\nmegabytes, but a caller can write `SELECT repeat('x', 100000000)` and drive peak memory to 400 MB —\nnot through the result, which stays bounded at 300 bytes, but through the cost guard's own\n`EXPLAIN`, which PostgreSQL fills with the constant it folded while planning. That is a named\nresidual risk in [`THREAT_MODEL.md`](THREAT_MODEL.md), with the three repairs that were tried and\nwhat each one broke. Until it is closed, the memory limit is the thing that holds, so set one:\n`--memory` here, `MemoryMax=` under systemd.\n\n`MCP_ADDR` must bind `0.0.0.0` and not `127.0.0.1`, or the server listens on an interface that only\nexists inside the container and the published port answers nothing. The other easy one: `localhost`\nin `DATABASE_URL` means *the container*, not your machine, so a PostgreSQL running on the host needs\n`host.docker.internal` (Docker Desktop) or the host's address on the bridge (`172.17.0.1` by default\non Linux). Both of these were walked end to end against the published image before being written\nhere, including that a read returns rows and `DROP TABLE` comes back as\n`-32602 non-read-only statement: Drop`.\n\n**From source** — `cargo build --release` in a clone. Not `cargo install`: this crate is not on\ncrates.io, and an instruction that fails is worse than one that is missing.\n\n### Checking what you downloaded\n\nEvery released binary is signed with [Sigstore](https://www.sigstore.dev/) keyless signing — there\nis no private key for us to lose, and the certificate names the workflow, repository and tag that\nproduced the file. Each artefact ships with a `.sig` and a `.pem` beside it:\n\n```bash\nF=postgres-mcp-hardened-x86_64-unknown-linux-gnu.tar.gz\ncosign verify-blob \"$F\" --bundle \"$F.bundle\" \\\n  --certificate-identity-regexp '^https://github.com/Eszetael/postgres-mcp-hardened/' \\\n  --certificate-oidc-issuer https://token.actions.githubusercontent.com\n```\n\nPin the identity, not just the signature. Without `--certificate-identity-regexp` and\n`--certificate-oidc-issuer` the check answers \"somebody signed this\", which is not the question.\nA verified certificate names the workflow, the repository and the tag that built the file — you\ncan read it with `base64 -d \"$F.pem\" | openssl x509 -noout -text` (cosign writes the certificate\nbase64-encoded, which surprises people who try `openssl` on it directly).\n\nOlder cosign builds predate `--bundle`; separate `.sig` and `.pem` files are published alongside\nfor them, used as `--signature \"$F.sig\" --certificate \"$F.pem\"`. Current cosign marks those flags\ndeprecated, so prefer the bundle.\n\nPublic releases additionally carry SLSA build provenance, verifiable with\n`gh attestation verify <file> --repo Eszetael/postgres-mcp-hardened`.\n\n### Use it in Claude Desktop / Cursor (stdio)\n\n```json\n{\n  \"mcpServers\": {\n    \"postgres\": {\n      \"command\": \"postgres-mcp-hardened\",\n      \"args\": [\"--stdio\"],\n      \"env\": { \"DATABASE_URL\": \"postgres://readonly_user:YOUR_PASSWORD@localhost:5432/mydb\" }\n    }\n  }\n}\n```\n\n### Or run it as a remote server (Streamable HTTP)\n\n```bash\nDATABASE_URL=\"postgres://readonly_user:YOUR_PASSWORD@host:5432/mydb\" \\\nMCP_ADDR=\"0.0.0.0:8080\" \\\npostgres-mcp-hardened\n# POST /mcp   ·   GET /health   ·   GET /ready   ·   GET /metrics\n```\n\n> **TLS:** connections to PostgreSQL are encrypted whenever the server supports it, and\n> `sslmode=require`, `verify-ca` and `verify-full` are all accepted (the certificate chain *and*\n> the hostname are always verified, so `require` behaves like `verify-full`) — so managed Postgres (RDS, Supabase, Neon, Render)\n> works out of the box. Certificates and host names are **always verified** — *verified* (acceptance: \"a certificate naming another host is refused, by name\"); for a private CA, point\n> `MCP_SSLROOTCERT` at the PEM bundle. There is no \"trust anything\" switch.\n>\n> **Tip:** point `DATABASE_URL` at a **least-privilege read-only role**. The server enforces read-only itself, but a scoped DB role is defense-in-depth.\n\n### Or run it on a container platform (Apify Standby)\n\nThe server needs no code changes to run as an Apify Actor in Standby mode. It reads the port the\nplatform assigns from `ACTOR_WEB_SERVER_PORT` and binds `0.0.0.0` there — that port wins over\n`MCP_ADDR`, loudly, on stderr, because binding anywhere else means the run is never marked ready and\nthe failure looks like a mysterious timeout. `GET /` answers the platform's readiness probe\n(`x-apify-container-server-readiness-probe`) without touching the database: container readiness is\nnot database readiness, and a probe that waits on a busy pool turns a slow database into a container\nthat never starts.\n\n| Endpoint | Method | Purpose |\n|---|---|---|\n| `/mcp` | POST | the MCP endpoint (Streamable HTTP). `DELETE` ends a session. |\n| `/` | GET | readiness probe; otherwise a signpost naming the real endpoint |\n| `/health` | GET | the process is alive |\n| `/ready` | GET | the process **and** a database connection are available |\n| `/metrics` | GET | counters (needs `MCP_METRICS_TOKEN`) |\n| `/.well-known/mcp/server-card.json` | GET | what a registry reads: revisions, transports, tools |\n\n**Input** is a JSON-RPC request in the POST body — `initialize`, `tools/list`, `tools/call`,\n`resources/list`, `resources/read`, `server/discover`. **Output** is a JSON-RPC response; from\n`2025-11-25` a refused statement comes back as a tool execution error (`isError: true`) with the\nreason in the content, so the model can rewrite the query. `tools/list` is the authoritative\ndescription of every argument.\n\n**Authentication there is the platform's, not ours.** Apify checks the caller's token before routing\nto the container, so the server does not additionally demand `MCP_BEARER_TOKEN` — requiring a second\nsecret would mean an agent that finds this server cannot call it. That exemption is narrow: it needs\n**both** `APIFY_IS_AT_HOME` and `ACTOR_WEB_SERVER_PORT`, one alone changes nothing, and the server\ncard then reports `\"type\": \"apify-platform\"` rather than claiming a lock we do not hold. Everywhere\nelse the server still refuses to start on a network address with no authentication. Set\n`MCP_BEARER_TOKEN` as well if you want a second lock on the same door.\n\nThe other start gate is unchanged and matters more here: a role that can write is refused a network\nlistener. Point `DATABASE_URL` at a read-only role — `--print-setup-sql` writes the statements.\n\n## Migrating from the deprecated server\n\nThe most-discussed problems reported against `@modelcontextprotocol/server-postgres` were\nreproduced against this server; here is how each behaves:\n\n| What people reported | Here |\n|---|---|\n| Two instances (prod + dev) are indistinguishable, the client picks one | Resource URIs carry the database name (`postgres:///mydb/public/orders/schema`) and `MCP_SERVER_LABEL` names the instance in the client UI |\n| One database per instance, because the connection string is a command-line argument | `MCP_DATABASE_URLS=\"prod=…;dev=…\"` serves several databases from one server; every tool takes an optional `database`, and resources span all of them |\n| `no pg_hba.conf entry … SSL off` | The error says the server requires TLS and names the fix (`?sslmode=require`) |\n| Read-only bypassed by injecting `COMMIT` / `END` | Rejected — the multi-statement gate works on tokens, before the parser, and `COMMIT` alone is refused as a write |\n| `spawn npx ENOENT`, Node version problems | A single native binary; no Node, no npx, no `node_modules` |\n| Hangs indefinitely against RDS with no output or error | Bounded: an unreachable host answers in ~8 s with the reason, never silently |\n| `self-signed certificate in certificate chain` | Point `MCP_SSLROOTCERT` at the CA bundle; the error names that variable |\n| Connection string only as a command-line argument | `DATABASE_URL` **or** the positional argument — the original invocation keeps working |\n| `INVALID_URL` with special characters in the password | The error says which characters to percent-encode, and how |\n| Partition children flood the table and resource lists | Hidden by default; `MCP_SHOW_PARTITIONS=1` brings them back |\n| `-32601 Method not found`, `Unexpected end of JSON input` | `ping` and resources implemented; multi-line JSON is buffered until complete; batches are refused with a clear error rather than silence |\n| No row limit — one query floods the context | Auto-`LIMIT`, an 8 MB byte cap, and an explicit `truncated` flag |\n\n## Testing\n\nBeyond unit tests, the repository carries two harnesses that run in CI on every change:\n\n- `--fuzz` — a deterministic fuzzer that mutates a corpus of known writes with transformations\n  that do not change SQL meaning (comments, case, dollar-quoting, invisible Unicode, parentheses)\n  and asserts that none of them ever becomes an allowed statement.\n- `tests/acceptance.sh` — an end-to-end suite that starts its own PostgreSQL and checks 317\n  behaviours: every write-bypass reported against the deprecated server (including the\n  `COMMIT`/`END` injection), truthful results, schema introspection, protocol conformance,\n  configuration mistakes failing loudly, audit tamper detection, fair use under load, and\n  multi-database deployments.\n\n## Every reported problem, answered\n\n[`docs/COMMUNITY_ISSUES.md`](docs/COMMUNITY_ISSUES.md) is the complete ledger: every problem\nreported against the deprecated server and every open issue against the maintained alternatives,\neach with what happens here — including the handful we could not fix in code, said plainly.\n\n## What we learned from the alternatives\n\nEvery server in this space has an issue tracker, and those trackers are a map of what goes wrong.\nThe ones we deliberately built against:\n\n- **A published image that lags the code.** The most-supported open complaint against the leading\n  alternative. Our container is built and pushed from the same tag that produces the binaries, so\n  it cannot drift.\n- **A hardcoded query timeout.** Also among their most requested settings. `MCP_STATEMENT_TIMEOUT`\n  is configurable and validated at startup.\n- **Unrestricted access by default.** Some servers default to read/write and rely on the operator\n  to restrict it. This one has no write path at all.\n- **Credentials in the client configuration.** `MCP_PASSWORD_FILE` keeps the password out of it.\n- **Tables in a non-default schema silently not found.** `MCP_SEARCH_PATH` fixes the lookup, and\n  the tools take an explicit `schema` anyway.\n- **Deprecated transport.** HTTP+SSE was replaced by Streamable HTTP in **2025-03-26**, three\n  revisions ago (this page said 2025-06-18 until 0.1.7, which was wrong by one revision; the\n  specification's own changelog for 2025-03-26 records the replacement). We speak the current\n  transport.\n\n## Troubleshooting\n\nAnswers to the questions people actually asked about the deprecated server, so nobody has to open\nan issue to find them.\n\n**`spawn npx ENOENT` / \"which Node version do I need?\"** — none. This is a single native binary, with no runtime to install beside it.\nDownload it from the releases page and point your client\nat the file. There is no `node_modules`, no `npx`, nothing to keep up to date.\n\n**\"The server starts but nothing is listening on a port.\"** — that is stdio mode, which is correct\nfor Claude Desktop and Cursor: the client talks to the process over its standard input and output,\nnot over a socket. If you want a network endpoint, start it without `--stdio`; it then prints\n`MCP HTTP listening on http://…` and speaks Streamable HTTP.\n\n**\"Can my client on another machine reach the database?\"** — yes: run the server next to the\ndatabase in HTTP mode, expose it, and enable OAuth (`JWT_PUBKEY_PEM`, `JWT_AUD`, `JWT_ISS`). The\ndatabase credentials then never leave the host the server runs on.\n\n**\"Could not attach to MCP server.\"** — the process exited before the handshake. Run the same\ncommand in a terminal: a configuration mistake prints its reason and exits with status 2 rather\nthan dying quietly, and a connection problem is reported on the first query with the cause.\n\n**`self-signed certificate in certificate chain` / `unable to verify the first certificate`** —\nyour provider uses a private CA (Supabase, GCP and RDS all do). Download their CA bundle and set\n`MCP_SSLROOTCERT` to it. The error message names the step for your provider. We do not offer a\n\"trust anything\" switch.\n\n### Managed providers\n\nThis server **always verifies the database certificate**, including with `sslmode=require`. That is\na deliberate deviation from libpq, where `require` encrypts without verifying and a machine in the\nmiddle can therefore read and rewrite every query and result without anyone noticing. The cost of\nbeing strict is that a provider with a private CA needs one extra step; the cost of being lax is\nthat you never find out. If you disagree with the trade-off, `verify-full` with the bundle below is\nthe same amount of work and leaves no doubt either way.\n\n| Provider | What to expect |\n|---|---|\n| **Supabase** | Private CA. Dashboard → Project Settings → Database → SSL configuration → download the certificate, then set `MCP_SSLROOTCERT` to it. The direct host (`db.<ref>.supabase.co`) is **IPv6-only** — on an IPv4 network use the Supavisor pooler string (port 6543), which also fits serverless and short-lived connections. |\n| **Amazon RDS / Aurora** | Private CA: `https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem`. IAM authentication works — put the generated token in the password field, and remember it expires in 15 minutes. |\n| **Google Cloud SQL** | Private CA: Connections → Security → `server-ca.pem`. Through the Cloud SQL Auth Proxy, connect to the proxy on localhost and TLS is the proxy's business. |\n| **Azure Database for PostgreSQL** | Public CA — nothing to download. Azure rotated its root to DigiCert Global Root G2 during Q1 2026; we ship the Mozilla root store, so the rotation needs nothing from you. |\n| **Neon** | Public CA (ISRG Root X1, Let's Encrypt) — nothing to download. Pooled and direct endpoints both work. |\n| **DigitalOcean** | Private CA: download the certificate from the cluster's Overview page. |\n\nNot sure which case you are in? Ask the server itself, before configuring anything:\n\n```bash\necho | openssl s_client -starttls postgres -connect YOUR_HOST:5432 2>/dev/null \\\n  | openssl x509 -noout -issuer\n```\n\nA well-known issuer (DigiCert, ISRG, Google Trust Services) means it will just work; anything\nnaming your provider means you need their bundle.\n\nWith a connection pooler (Supavisor, PgBouncer) in transaction mode, note that this server sets\n`statement_timeout` and `idle_in_transaction_session_timeout` per session and runs every query in an\nexplicit read-only transaction. Both are compatible with transaction pooling; session-level\n`SET` outside a transaction is not, which is why we do neither.\n\n**`no pg_hba.conf entry … no encryption`** — the server accepts only TLS connections for that\nhost and user. Add `?sslmode=require` to the connection string.\n\n**`INVALID_URL` / `invalid connection string`** — a password containing `@`, `:`, `/`, `#` or `?`\nmust be percent-encoded (`@` → `%40`, `:` → `%3A`, `/` → `%2F`, `#` → `%23`).\n\n**\"My table has hundreds of partitions and the list is unusable.\"** — partition children are hidden\nby default; the parent is listed. Set `MCP_SHOW_PARTITIONS=1` if you need them.\n\n**\"I need production and staging at the same time.\"** — either run one server per database (they\nare distinguishable: set `MCP_SERVER_LABEL`), or configure both in one server with\n`MCP_DATABASE_URLS` and pass `database` in the tool arguments.\n\n## Resources\n\nEvery table and view is exposed as an MCP **resource** (`postgres:///<schema>/<table>/schema`), so a\nclient can browse the schema without issuing a query — the same capability the deprecated server\noffered, plus column comments, primary keys and **foreign keys** in the payload.\n\nWhen the database has not answered, `resources/list` returns an **empty list with the reason in\n`_meta`** rather than a protocol error. A catalogue inspecting this server starts it with no\ndatabase at all and calls `resources/list` straight after `initialize`; answering that with an error\nreads as a server that does not work. The empty list is not a claim that there are no tables —\n`initialize` says the database has not answered, `security_posture` gives the detail, and the reason\ntravels with the list itself. A database that *does* answer and refuses is still an error, because\nreporting \"no resources\" for a missing privilege is the silent failure this server exists to avoid.\n*verified* (acceptance: \"a host can inspect the server with no database and mcp-proxy in front\")\n\n## Tools\n\n- **`explain_query`** — the execution plan; with `analyze` it runs the statement and reports real\n  timings and buffer usage, which is safe here because the statement is validated read-only and runs\n  inside a transaction that is always rolled back. The plan comes with a `summary`: which node spent\n  the time (self time, not inclusive), and where the planner's row estimate was furthest from\n  reality — because a bad estimate is usually why the plan is bad.\n- **`database_health`** — cache hit ratio, connections (this database and the cluster), the longest\n  running statement and the longest abandoned transaction as separate figures, vacuum backlog,\n  invalid indexes, sequences near their ceiling, replication lag, **tables that have never been\n  analysed** (no planner statistics — the usual reason a database looks healthy and runs slowly), and\n  the window the counters cover. Anything the role cannot see is declared rather than returned as a\n  confident zero.\n- **`analyze_indexes`** — unused indexes, duplicates, and tables scanned sequentially often enough\n  that an index would pay off.\n- **`top_queries`** — the heaviest statements, from `pg_stat_statements`.\n- **`security_posture`** — what this deployment is actually able to do to your database, asked of\n  PostgreSQL rather than assumed: whether the role can write, bypass row-level security or reach\n  server files; whether the transport is authenticated; whether the audit chain is keyed; whether the\n  connection is encrypted. Returns a grade — the worst finding, never an average — and, for anything\n  wrong, the command that fixes it. The same summary reaches the model through `initialize`, because\n  under stdio nobody sees stderr and the agent is the only messenger the operator has.\n- **`query`** — run a read-only SQL query (validated, auto-`LIMIT`, cost-guarded). The response\n  states what it did: `returnedRows`, `appliedLimit`, `truncated`, plus `requestedLimit` when a\n  larger request was capped at the 10000-row maximum, `offset` when paging, and `redactedColumns`\n  when masking is configured — so an agent never has to guess whether it received the whole answer.\n- **`list_schemas`**, **`list_tables`**, **`describe_table`** — progressive schema discovery\n  (parameterized, injection-safe). `describe_table` returns the **schema comments**\n  (`COMMENT ON TABLE/COLUMN`), primary keys, **foreign keys** and defaults, so the agent reads what a\n  column *means* and what it points at instead of guessing from its name — and a missing table is an\n  error, not an empty column list.\n\n## Protocol revisions\n\nThe server answers `initialize` with the revision the client asked for when it implements it, and\nwith its newest otherwise. Over HTTP the revision comes from the `MCP-Protocol-Version` header, per\nrequest — one client's negotiation cannot change the contract another client is served under.\n\nIf a request carries no header, the server does not fall straight back to the oldest contract. It\nreads the revision **this** session agreed on at `initialize`, which is what the transport\nspecification asks for: the default applies only \"if the server does not receive an\n`MCP-Protocol-Version` header, and has no other way to identify the version — for example, by\nrelying on the protocol version negotiated during initialization\". A session is that other way, so a\nclient that negotiated `2025-11-25` and then omitted the header keeps the contract it agreed to\nrather than being silently demoted.\n\nA header we cannot parse is a different matter from a header that is absent, and the specification\nis explicit about it: \"If the server receives a request with an invalid or unsupported\n`MCP-Protocol-Version`, it **MUST** respond with `400 Bad Request`.\" A version we do not implement —\n`2025-03-26` or `not-a-date` — is refused with `400`\nand the list of revisions we do speak, rather than served under a contract the client never agreed\nto. Falling back is for silence, not for disagreement.\n\nOnly a request with neither a header nor a session falls back, and it falls back to `2025-06-18` —\nthe oldest revision this server implements — rather than the `2025-03-26` the specification names.\nThat revision is not implemented here, and answering under a contract the server cannot honour would\nbe worse than answering under the oldest one it can.\n\nThe difference that matters is where a refusal goes. Under `2025-06-18` \"this statement is not\nread-only\" was a JSON-RPC error: the client saw a broken call and the model often never saw the\nreason. From `2025-11-25` (SEP-1303) it arrives as a tool execution error — `isError: true` with the\nreason in the content — so the model rewrites the query instead of handing the user a failure. What\ndoes not change is the audit: the refusal is recorded by the code that refuses, and the acceptance\nsuite asserts both halves together, so friendlier errors can never quietly mean a quieter log.\n\n`Mcp-Method` and `Mcp-Name` are held to **agreement, not presence**. The draft requires them; earlier\nrevisions do not, and demanding them would break every client shipping today. But a gateway that\nroutes or authorises on `Mcp-Method` while the server executes the body has decided about a\ndifferent request than the one that runs — and that is true whatever revision is in force. So a\nheader that is present must match the body under every revision, while a client that sends none is\nuntouched.\n\nProtocol failures stay protocol failures. A malformed envelope, an unknown method or a missing token\nis not something a model can fix by rewriting SQL, and a client's error handling expects those where\nthey have always been.\n\n### The next revision, before it lands\n\n`2026-07-28` is the largest break MCP has had: no `initialize`, no session header, no `ping`. That\nidentifier comes from `LATEST_PROTOCOL_VERSION` in the draft schema, and it is not a release date —\nMCP names a revision for the last date a backwards-incompatible change was made, so it describes the\ndraft's history rather than a schedule. Every request carries its own protocol version in `_meta`,\nand a new `server/discover` replaces the handshake. We implemented it early behind a switch, because\na draft moves and a server advertising support for a moving target will be wrong in public. Upstream\ncut `schema/2026-07-28` on 2026-08-03 — the released schema differs from the draft we had verified\nagainst in four documentation URLs and nothing else — so the switch is gone and this is what the\nserver speaks by default. Clients on `2025-11-25` and `2025-06-18` are answered as before.\n\n`server/discover` answers under **every** revision, because the specification\nexpects clients to use it as a backwards-compatibility probe — which only works if older servers\nanswer it. Ours answers with the revisions we speak and, in `_meta`, the full security posture. That\nis deliberate: a client can learn it is talking to a server connected as a superuser *before* it\nsends a query, as structured data rather than prose a model has to notice.\n\nTwo of the draft's rules are security controls here, not formalities. `Mcp-Method` and `Mcp-Name`\nmust agree with the request body, and we refuse the mismatch (`-32020`) — the headers exist so a\ngateway can route and authorise without parsing the body, and if header and body may disagree, then\nthe thing that authorised and the thing that executes saw two different requests. And a client\nstating a version we do not implement is told so (`-32022`) rather than quietly served under a\ncontract it never agreed to.\n\n## What the safety costs\n\nMeasured, not asserted: `tests/bench/`, against the `pg` driver running the same query on the same\nmachine (PostgreSQL 18.6 in Docker, 50k-row table, 300 sequential requests, rate limit off).\nRe-measured 2026-08-17 on a shared VPS at load average 2.6 — median of two runs:\n\n| query | driver | this server | difference |\n|---|---|---|---|\n| point lookup | 0.43 ms | 5.9 ms | +5.5 ms |\n| small scan | 1.0 ms | 8.3 ms | +7.3 ms |\n| aggregate | 4.7 ms | 11.3 ms | +6.6 ms |\n\nAn earlier table here said +3.6/+5.2/+3.7 and \"about 4 ms\". Those came from a quieter machine, and\nthe driver floor moved with them — 0.28 ms against today's 0.43 for the same lookup — so it was the\nhardware talking, not the code. Two things were checked before changing the number, because the\nobvious suspect was our own build: the 0.1.6 binary, built before `lto` was switched on, measures\n+5.4/+7.8/+5.9 on this machine within the same hour. Identical. The release profile halved the\nbinary and cost nothing here.\n\nExpect **5 to 8 ms per query**, and treat any single figure on this page as a reading from one\nmachine on one day. The shape matters more than the size: the overhead is nearly constant. If the AST validation were the cost, it would grow with the query. It does not. The time\ngoes on round trips — the session is reset, the timeouts and read-only flag are set, a read-only\ntransaction is opened, the cost guard plans the statement, then the query runs and the transaction\nis rolled back. Five or six exchanges where the driver has one.\n\nThat is a deliberate trade and you can see exactly what it buys. For an agent making tens of calls\nit is invisible; if you are putting this in front of a latency-critical serving path, you are using\nthe wrong tool, and it is not one.\n\nUnder concurrency the interesting number is not throughput but what happens past the limits: at 8\nconcurrent clients it served 373 requests/second and turned away 192 more with \"too many requests in\nflight\", which is the in-flight cap doing its job rather than a queue growing until something falls\nover.\n\n## Would this index help? — answered without creating one\n\nThe one capability the leading alternative is genuinely known for is index tuning: it can tell you\nan index would pay off before you build it. It gets there by defaulting to a connection that can\ncreate real indexes — safe only if you remembered to restrict it.\n\n`simulate_index` answers the same question from a connection that cannot write anything.\n[hypopg](https://github.com/HypoPG/hypopg) registers a hypothetical index in backend memory: the\nplanner sees it, storage never does, and it is gone when the call returns. You get the plan and cost\nwith and without, and — separately — whether the planner actually reached for it, because a cost\nthat barely moves and an index the planner ignored are different answers.\n\nThe tool takes a table and a list of columns. **Not a `CREATE INDEX` statement.** The definition is\nassembled server-side from identifiers the catalogue confirmed exist, quoted by PostgreSQL itself,\nso there is no path from a tool argument to arbitrary DDL — a column name carrying SQL dies on the\ncatalogue lookup, and there is a test that fires exactly that. The numbers are planner estimates:\ntreat a large improvement as a reason to test the index, not as proof.\n\n## Conformance is checked by somebody else's client\n\nEvery other test here is our harness talking to our server. If we misread the specification, we\nmisread it the same way in both halves and everything passes. So CI also drives the server with the\n**official MCP SDK** — the client library the ecosystem uses — over stdio and Streamable HTTP:\nhandshake, tool listing and schemas, a read, a refused write arriving as a tool execution error\nrather than a protocol one, resource listing and reading. A protocol mistake shows up as a client\nthat cannot talk to us. `tests/conformance/`.\n\n## Working on this\n\n```bash\ngit config core.hooksPath .githooks   # once, per clone\n```\n\n`.githooks/pre-push` runs format, clippy, the unit tests and the documentation-claim checks before\nanything leaves your machine. It exists because of a specific mistake: a commit went out with a\nfailing clippy lint, and the first anyone knew of it was a failure email. Note that `cargo test`\npasses on that code — clippy lints are not compiler errors — so \"it builds locally\" is not the\nsame answer as \"CI will be green\".\n\nIt deliberately skips the acceptance suite and the PostgreSQL matrix: those need Docker and about\ntwenty minutes, and a hook people cannot afford to run is a hook people bypass. CI runs everything.\n`git push --no-verify` when you want to see something fail in CI on purpose.\n\n## Setting up the role\n\n```bash\nDATABASE_URL=postgres://admin@host/mydb postgres-mcp-hardened \\\n  --print-setup-sql --role mcp_reader --schemas public --redact ssn,email > setup.sql\n# read it, then:\npsql -v pw=\"$(openssl rand -base64 24)\" -f setup.sql mydb\n```\n\nRun with a connection string and the table and column lists come from the catalogue; without one you\nget the same document with placeholders. The difference matters most for redaction: the columns to\ngrant back have to be read from the database, because writing them from memory is how a column meant\nto stay hidden gets handed back.\n\nThe output ends with checks that return no rows when it worked, and a reminder that the server itself\nwill tell you what the role can do the moment you point it at the database.\n\n## Limiting what the server can reach\n\n`MCP_ALLOW_SCHEMAS` and `MCP_ALLOW_TABLES` restrict which relations a query may touch. Either one\nturns the allowlist on; `schema.*` and `schema.table` both work.\n\n```bash\nMCP_ALLOW_TABLES='public.customers,public.orders,analytics.*'\n```\n\nThe check reads the **query plan**, not the SQL. That is the whole design: the planner has already\napplied `search_path`, resolved every alias, expanded views to base tables, and knows that a CTE\nnamed `customers` is not the table `customers` — so `WITH customers AS (SELECT 1) SELECT * FROM\ncustomers` runs and touches nothing, while `WITH x AS (SELECT * FROM salaries) SELECT * FROM x`\nis refused. Reading the statement instead is what lost three rounds of adversarial review.\n\nTwo consequences worth knowing before you turn it on:\n\n- **A partition rides on its parent.** You allow `events`; PostgreSQL decides which children to read.\n- **A view needs its base tables allowed too**, because the plan names those. Allow both, and let the\n  database privileges keep the base table unreachable directly — that is the boundary in any case.\n\n`pg_catalog` and `information_schema` are outside the surface unless `MCP_ALLOW_CATALOG=1`: an agent\nthat can still read the catalog can enumerate exactly what the allowlist was meant to hide. The\nschema tools keep working, because they run fixed queries rather than caller SQL.\n\nThat covers three routes to the same facts, because for a while it covered only one. A catalogue view\nplans to scans over real relations and the plan names them — but `pg_settings` plans to a single\n`Function Scan` on `pg_show_all_settings`, naming no relation at all, and `current_setting()` is a\nscalar call that never appears as a scan. Both used to return the server's configuration under an\nactive allowlist. Functions whose name begins with `pg_`, plus `current_setting`, `inet_server_addr`\nand `inet_server_port`, are now refused alongside the catalogue relations, and `MCP_ALLOW_CATALOG=1`\nopens all of them together. Ordinary set-returning functions — `generate_series`, `jsonb_each`,\n`unnest`, `regexp_split_to_table` — carry no such prefix and are unaffected. `current_user`,\n`session_user`, `current_database` and `version` stay readable on purpose: an agent already knows what\nit connected to and as whom.\n\n## The corpus of things that got through\n\nEvery shape that defeated a control during review lives in `tests/adversarial/`, with the round in\nwhich it stopped working. It runs on every build, and — because the cases are written against\nplaceholders rather than our fixture — you can point it at your own database:\n\n```bash\nADV_URL='postgres://…' \\\n  ADV_TABLE=people ADV_TABLE2=orders ADV_REDACT_COL=ssn \\\n  ./tests/adversarial/run.sh\n```\n\n`ADV_TABLE` needs the sensitive column, `ADV_TABLE2` is any other readable table, `ADV_REDACT_COL`\nis the column to redact. All three matter: this example omitted `ADV_TABLE2` until 0.1.7, so it kept\nits default of `film` — a table from the Pagila sample database — and following the instruction\nexactly produced three \"mismatches\" that were only a missing relation. The harness now checks the\nthree up front and says which one is wrong, because a security corpus that reports a typo as a\nfailed control teaches you to ignore the failures that matter.\n\nA security claim you can only check by reading our source is a claim you have to take on trust, and\nthis project's own history is the argument against that.\n\n## Security model\n\nThe full statement of what this server guarantees, what it does not, and which control enforces\nwhich promise is in [THREAT_MODEL.md](THREAT_MODEL.md) — including the controls that have been\ndefeated in review and are therefore described as depth rather than as boundaries.\n\n- **Encrypted transport:** TLS to PostgreSQL via rustls (no OpenSSL in the image), certificate\n  verification always on, private CAs via `MCP_SSLROOTCERT`.\n- **Read-only, two ways:** every statement is parsed with `sqlparser` and rejected unless it's a `SELECT`/`WITH`/`EXPLAIN`/`SHOW`; the DB session is additionally set `default_transaction_read_only = on`.\n- **Anti-DoS:** enforced `statement_timeout`, auto-injected `LIMIT`, and an `EXPLAIN`-based cost guard that rejects expensive plans before they run.\n- **Sensitive columns — defence in depth, and honest about it:** `MCP_REDACT_COLUMNS` masks values\n  at every depth and refuses to run a query that references those columns, including the ways round\n  it that an adversarial panel actually found — renaming (`SELECT password AS pw`), wrapping\n  (`md5(password)`), serialising the whole row (`row_to_json(t)`, `t::text`, `json_agg(t)`), and\n  naming the column as a string rather than an identifier (`to_jsonb(t) ->> 'password'`,\n  `#>> '{password}'`, `$.password`), whole-row wildcards (`ROW(t.*)::text`) and positional renaming\n  (`(SELECT * FROM staff) AS x(c1, …, c9)`). It is still name-based filtering, and name-based\n  filtering cannot be a boundary against the whole SQL language — four adversarial rounds each got\n  past it through a shape nobody had listed.\n\n  The fourth, in 0.1.7, did not find a new *shape* of name. It went around names altogether:\n  `SELECT get_raw_page('people', 0)` hands back 8192 bytes of the table as the disk holds it, and\n  every value on that page is in there, including the redacted one. Demonstrated, not theorised —\n  with `MCP_REDACT_COLUMNS=ssn`, `SELECT ssn FROM people` was refused while the raw page came back\n  with the social security numbers in plain ASCII. `pageinspect` and its relatives are now refused\n  as a category of their own, because \"this returns storage rather than columns\" is a different\n  problem from \"this writes\", and telling an operator which one they hit is worth a separate\n  message.\n\n  The same round found the quieter version of it. PostgreSQL's planner keeps a sample of each\n  column's real values, and `pg_stats` publishes them: with 3,000 rows, `SELECT * FROM pg_stats\n  WHERE tablename='people'` returned `{123-45-6789,555-00-1111,987-65-4321}` while `SELECT ssn FROM\n  people` was refused. That query never names the redacted column, so a name-based rule has nothing\n  to act on. The value-bearing statistics columns — `most_common_vals`, `histogram_bounds`,\n  `most_common_elems`, `stavalues1`…`stavalues5` — now join whatever you configure, whenever you\n  configure anything. The rest of the view is untouched: `n_distinct`, `null_frac` and `correlation`\n  are what the index advice below is built from and they carry no values, so removing the whole\n  relation would have broken ten columns to fix four.\n\n  Two more doors turned out to open on the same room. A value too long for its row is stored in a\n  TOAST table, and that table is readable by name: `SELECT chunk_data FROM pg_toast.pg_toast_16384`\n  returned the redacted text in the clear. `pg_largeobject` is the same thing for large objects — the\n  bytes underneath `lo_get`. Both are refused now, as *relations* rather than functions, with a\n  message that says why: they hold physical storage rather than columns. The catalogue that merely\n  describes the database is untouched — `pg_tables`, `pg_stat_activity`, `pg_largeobject_metadata`,\n  and the statistics columns index advice needs.\n\n  All four say the same thing, and it describes this feature's limit better than any list of patches:\n  **a column filter protects columns, so anything that reads underneath columns is outside what it\n  can promise.** Raw pages, planner samples, TOAST chunks and large-object bytes are four doors into\n  that space, all four found in a single afternoon by looking for the shape rather than the names.\n  The honest assumption is that there are more, which is why the database role and the read-only\n  transaction are the real boundary and this stays what it says it is: defence in depth.\n\n  So the server stops asserting and **asks the database**: at startup it reports every table where\n  the connected role can still read a redacted column, with the exact statements that fix it, and\n  `MCP_REDACT_REQUIRE_REVOKE=1` turns that report into a refusal to run. Note the fix is a table-level\n  `REVOKE` followed by a `GRANT` of the columns that stay — a bare `REVOKE SELECT (password) ON staff`\n  is silently a no-op while the role holds SELECT on the whole table. With column-level grants\n  PostgreSQL then refuses `SELECT *` on that table, so callers name columns instead; `describe_table`\n  lists them and marks the redacted one.\n- **Prompt-injection aware:** row data is returned inside a `trusted=\"false\"` provenance block with delimiters escaped, so a malicious cell can't hijack the agent.\n- **It generates the role you should be running as:** `--print-setup-sql` writes the DDL for a role\n  that inherits nothing, bypasses nothing, creates nothing, reads only the relations you name, and —\n  where you have named sensitive columns — has them revoked in the order that actually works. It\n  prints; it never executes. Applying this needs administrative rights, and a tool whose whole\n  identity is \"read-only\" has no business holding an administrator's password.\n- **It will not expose a role that can write:** when the listen address is reachable from the\n  network, the server asks PostgreSQL what the connected role is actually allowed to do — superuser,\n  `BYPASSRLS`, membership of `pg_write_all_data` and friends, and write privileges on a bounded sample\n  of tables — and refuses to start if the answer is more than \"reader\", naming each reason and\n  pointing at `--print-setup-sql`. It refuses an unauthenticated network listener for the same reason.\n  Loopback and stdio are left alone: there the caller is the operator. The overrides\n  (`MCP_ALLOW_EXCESSIVE_ROLE`, `MCP_ALLOW_ANONYMOUS_NETWORK`) take the literal value\n  `i-accept-the-risk` so they cannot be switched on by a typo, and they are recorded in the audit log.\n  This server enforces read-only itself, but that enforcement is code, and code has been wrong before;\n  a role that cannot write is the part no bug of ours can undo.\n- **A browser cannot reach it:** a request carrying an `Origin` is refused with 403 unless the\n  operator listed that origin, and on a loopback listener a `Host` that is not localhost is refused\n  too — the shape a DNS-rebinding attack takes when it aims at a database server on your laptop.\n- **The audit knows the configuration:** the chain opens with a `startup` record naming the version,\n  the transport and every setting in force, with connection passwords stripped and secrets reduced to\n  fingerprints, plus a `config_fp` an operator can pin across restarts. A log that says what happened\n  but not under which settings cannot answer the first question an incident asks.\n- **The audit notices being shortened:** a hash chain proves entries were not *altered*, but a log\n  with its tail cut off is internally consistent — recomputing it finds nothing wrong. Alongside\n  `MCP_AUDIT_LOG` the server therefore keeps `<log>.hwm`, a one-line record of the last sequence\n  number and hash it wrote, updated only after the entry is durably appended. On start the two are\n  compared, and a disagreement is reported: entries missing from the end, a rewritten last entry, or\n  a log that has gone away entirely. This is not proof of tampering — an unclean shutdown looks the\n  same — but a tamper-*evident* trail owes you the question, not the verdict. Keep the sidecar with\n  the log when you archive or move it; deleting it only loses the truncation check, never an entry.\n  The offline verifier is unchanged and still needs an external anchor:\n  `--verify-audit <file> --expect-last <hash>`.\n  *verified* (acceptance: \"a shortened log is noticed at startup, without any external anchor\")\n- **A wrong setting is fatal, not merely wrong:** an unparsable listen address, an audit file that\n  cannot be written, `sslmode=disable` to a database on another machine, a metrics token that is also\n  the database credential, a boolean spelt `yes` — each used to be accepted and quietly do something\n  other than what was meant. Startup now stops and names the setting.\n- **A misspelt setting is fatal — somebody else's setting is not:** `MCP_REDACT_COLUMN` (singular)\n  used to start the server with redaction quietly switched off, so a near miss of a real setting\n  still stops startup and names the intended spelling. A name that resembles nothing we define was\n  set by another program sharing the environment: it is reported and ignored. `mcp-proxy`, which\n  every catalogue puts in front of a server to inspect it, exports `MCP_PROXY_DEBUG` — until 0.1.6\n  that one variable made this server exit before reading a request. `MCP_X_*` remains reserved for\n  the operator's own use. *verified* (acceptance: \"a misspelling is still fatal\")\n- **No schema leaks:** database errors are mapped to structured, actionable messages that never echo table/column names.\n- **OAuth 2.1:** optional RS256 bearer-token validation (signature, `exp`, `aud`, `iss`) with scope enforcement; disabled when unconfigured for local/self-host use.\n- **Audit:** every tool decision is logged as a tamper-evident, hash-chained JSON line (no raw SQL).\n- **Supply chain:** dependency licences, sources and advisories enforced in CI (`cargo deny`,\n  `cargo audit`); a CycloneDX SBOM is attached to every release.\n- **Runtime:** ships as a distroless, non-root container — **14.8 MB to download**, 41 MB on disk for `linux/amd64` at 0.1.6, built and smoke-tested in CI. Both numbers, because a single one is always the flattering one: `docker images` shows the second, your bandwidth pays the first.\n\n## Footprint\n\nMeasured on an ordinary VPS against a 16k-row sample database, so you can check the \"written in\nRust\" claim rather than take it:\n\n| | |\n|---|---|\n| Resident memory, idle | 7.7 MB — median of five separate starts, all within 0.1 MB of each other |\n| Resident memory, after 200 requests | 9.4 MB, and flat afterwards |\n| Median request latency | ~8 ms — including the `curl` process the measurement spawns, so the server's own share is lower |\n| Start to first validated statement | 5 ms — median of five `--validate` runs, 5 to 7 ms observed |\n| Binary | 9.2 MB (linux x86_64, 0.1.7 onward). Nothing to install alongside it — no Node, no Python, no shared library we ship. It is *not* statically linked: like any `-gnu` target it uses the system `libc`, `libm` and `libgcc_s`. |\n| Container image | 12.6 MB compressed, 31.8 MB unpacked (linux/amd64, 0.1.7), distroless, non-root |\n\nTwo lines here were wrong until 0.1.7. Idle memory said 5.2 MB and measures 7.7 — five starts under\nidentical conditions landed within 0.1 MB of one another, so the old figure is not noise, it is a\ndifferent measurement whose method was not written down. The binary line said \"11 MB, static\", and the file people actually\ndownloaded was 18.9 MB and dynamically linked. The size was never measured on a release build,\nbecause this crate had no `[profile.release]` at all, so more than five megabytes of debug symbols\nshipped to every user. Setting `strip`, `lto` and `codegen-units = 1` took it to 9.2 MB. The word\n\"static\" was simply not true of any of the five targets we publish, none of which is a `musl` build.\nThe image shrank with the binary, from 14.8 MB compressed in 0.1.6 to 12.6 MB — both figures read\nfrom the registry manifest of the published image rather than from a local build, because a local\nbuild is not what anybody pulls.\n\nA twelve-minute soak of mixed traffic (reads, refusals, errors, aborted requests, session churn,\nunauthenticated requests) served **51,499 requests** and ended with **the same 15 open file\ndescriptors it started with**. Resident memory went from 8.1 MB to 11.2 MB, and the shape of that is\nthe interesting part: 8.1 to 10.7 happened inside the first 400 requests, and the remaining 51,000\nadded 0.5 MB in a curve that flattened as it went. That is an allocator settling, not a leak. This\npage used to say memory stayed \"flat\", which was true of everything after the first few seconds and\nnot true of the number, so here is the number.\n\nReproduce it with `tests/soak.sh` rather than believing the paragraph.\n\n## Configuration\n\n| Env | Purpose |\n|-----|---------|\n| `DATABASE_URL` | PostgreSQL connection string (use a read-only role) |\n| `MCP_ADDR` | HTTP listen address (default `127.0.0.1:8080`) |\n| `MCP_MAX_COST` | reject queries whose `EXPLAIN` cost exceeds this (default 1,000,000) |\n| `JWT_PUBKEY_PEM`, `JWT_AUD`, `JWT_ISS` | enable OAuth 2.1 token validation (omit to disable auth); the key may be the PEM text or a path to a PEM file |\n| `MCP_AUDIT_LOG` | path to the append-only audit log (hash-chained); verify with `--verify-audit <file> [--expect-last <hash>]`. The server also writes `<log>.hwm` beside it — the last sequence number and hash, used at startup to notice a shortened log |\n| `MCP_AUDIT_HMAC_KEY` / `MCP_AUDIT_HMAC_KEY_FILE` | key that turns the audit chain into HMAC-SHA256 — keep it off the host so the log cannot be rewritten (a trailing newline in the file is ignored) |\n| `MCP_AUDIT_HMAC_KEYS_OLD` | comma-separated previous keys, so a log that survived a key rotation still verifies. *verified* (acceptance: \"a chain spanning a key rotation verifies with both keys\") |\n| `MCP_REDACT_COLUMNS` | columns to keep out of results, e.g. `password, ssn, card_number` — masked at any depth and refused if referenced. Defence in depth, not a boundary: pair it with `REVOKE SELECT (col)` |\n| `MCP_BEARER_TOKEN` | shared token required on every request, for deployments without an identity provider. Ignored when OAuth is configured — accepting it as an alternative would give its holder full scope and leave the audit with no identity |\n| `MCP_STATEMENT_TIMEOUT` | query time limit (PostgreSQL interval, default `30s`) |\n| `MCP_SEARCH_PATH` | schemas to search when a table name is unqualified, e.g. `analytics, public` |\n| `MCP_PASSWORD_FILE` | read the database password",
  "bytes": 60000,
  "sha": "7a7aeaaaa551ed977fcd19f9af2e4c986f9f82a033cc66f6e0fae180a2749c2e",
  "repo_slug": "eszetael/postgres-mcp-hardened",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_eszetael_postgres_mcp_hardened_585d6340/readme"
}