Skip to main content
Glama
4l3x31s

mcp-postgres

by 4l3x31s
README.md
# mcp-postgres

An MCP server that gives an agent access to **one** PostgreSQL database, configured
**per project** rather than globally, and **read-only until you say otherwise**.

The agent can inspect the real schema, verify queries against a live plan, read data,
and — when permitted — modify data and tables.

## Why per project

Configuration comes from environment variables and a project-local `.env`, and the
server is registered in the project's `.mcp.json`. Two projects therefore get two
different databases with two different permission sets. There is no global config
file to leak one project's credentials into another's session.

## Tools

| Tool | What it does |
|---|---|
| `pg_info` | Database, role, schema, server version, and which operations are permitted. Call first. |
| `pg_list_schemas` | Non-system schemas with table/view counts. |
| `pg_list_tables` | Tables and views in a schema: column counts, estimated rows, size. |
| `pg_describe_table` | Columns, types, defaults, constraints, indexes, incoming FKs, view definition. |
| `pg_list_relationships` | Every foreign key touching a schema — the join graph in one call. |
| `pg_query` | One read-only statement. Runs in a `READ ONLY` transaction that is always rolled back. |
| `pg_explain` | Execution plan, optionally with `ANALYZE` timings. |
| `pg_execute` | Statements that change data or structure. Gated by the permission flags. |

## The safety model

Two independent layers, and the second one is the one that matters:

1. **Classification.** A SQL lexer strips comments and string literals, splits the batch
   into statements, and classifies each one as read / write / DDL / admin. Anything the
   configuration does not allow is refused with an explanation the agent can act on —
   before it reaches the database. Because it lexes rather than pattern-matches,
   `SELECT 'DROP TABLE users'` is a read, and `WITH d AS (DELETE ...) SELECT * FROM d`
   is a write.
2. **PostgreSQL.** `pg_query` runs inside `BEGIN READ ONLY` and always rolls back, so it
   cannot write even if layer 1 were wrong. Everything else is bounded by the privileges
   of the role you connect as.

**Layer 1 is a guardrail, not a security boundary. Layer 2 is.** Give the role only the
privileges the project actually needs — a read-only role for a read-only project.

Permission flags, all `false` by default, each unlocking only its own class:

| Flag | Unlocks |
|---|---|
| `PGMCP_ALLOW_WRITE` | `INSERT` / `UPDATE` / `DELETE` / `MERGE` |
| `PGMCP_ALLOW_DDL` | `CREATE` / `ALTER` / `COMMENT` / index / `VACUUM` |
| `PGMCP_ALLOW_DESTRUCTIVE` | `DROP`, `TRUNCATE`, and `DELETE`/`UPDATE` with no `WHERE` |
| `PGMCP_ALLOW_ADMIN` | `GRANT` / `REVOKE` / role management |

Enabling `WRITE` does not grant `DDL`; enabling `DDL` does not grant `DROP`. That is
deliberate — "let the agent fix a row" and "let the agent drop a table" are not the
same decision.

Refused unconditionally, with no flag that re-enables them: `COPY` (server-side file
access and `FROM PROGRAM`), `ALTER SYSTEM`, `SET ROLE` / `SET SESSION AUTHORIZATION`,
`LOAD`, and the file/remote functions `pg_read_file`, `lo_import`, `lo_export`, `dblink`
and friends. Those are host-compromise vectors, not database features.

Transaction control (`BEGIN`, `COMMIT`, `ROLLBACK`, `SET`) is also refused: the server
manages transactions so a half-open one can never be returned to the pool.

## Install it once

```bash
git clone <this repo> && cd mcp-postgres

python -m venv .venv
.venv/Scripts/activate          # Windows
# source .venv/bin/activate     # Linux/macOS
pip install -e .
```

For Docker installs, also build the image once:

```bash
docker build -t mcp-postgres:0.1.0 .
```

## Then set it up per project

From **the project you want the agent to work in**:

```bash
cd /path/to/your-project
mcp-postgres init
```

It asks for the six connection values and nothing else:

```
  PostgreSQL host: db.internal
  Port [5432]:
  User (role): app
  Password:
  Database name: shop
  Schema [public]:
```

`Port` defaults to `5432` and `Schema` to `public` — press Enter to accept either. The
other four are required; blank input just asks again.

Then it connects for real before writing anything, so a typo fails here rather than on
the agent's first query:

```
Verifying the connection...
  ok: PostgreSQL 17.10 -- 24 relations in schema 'public'
```

and writes three files into the project:

| File | Purpose |
|---|---|
| `.env` | Credentials, permissions and limits — everything tunable, with comments |
| `.mcp.json` | The server entry, merged in without disturbing other servers |
| `.gitignore` | Gains a `.env` line, unless it already ignores it |

Restart your MCP client, then ask it to call `pg_info`.

### Options

```
mcp-postgres init [--docker] [--http-port N] [--allow-write] [--allow-ddl]
                  [--project PATH] [--no-verify] [--force]
```

The install is read-only unless you pass `--allow-write` / `--allow-ddl`. You can always
flip the `PGMCP_ALLOW_*` flags in `.env` afterwards and restart. Destructive and admin
permissions have no flag on purpose — edit `.env` deliberately for those.

`--project` installs into a directory other than the current one. `--no-verify` skips the
connection check, for when the database is not up yet. `--force` overwrites an existing
`.env` without asking.

### Docker mode

```bash
cd /path/to/your-project
mcp-postgres init --docker --http-port 8765
docker compose -f docker-compose.mcp-postgres.yml up -d
```

`--docker` also writes a project-local `docker-compose.mcp-postgres.yml`, so **each
project runs its own container** against its own `.env`. It uses the prebuilt image, so
the project needs no copy of this source tree.

Two things the installer handles for you, because they are what usually breaks:

- **`localhost` is rewritten to `host.docker.internal`.** Inside a container, `localhost`
  is the container itself. The installer verifies the connection using the host you typed,
  then stores the one the container can actually reach — and tells you it did. The compose
  file maps `host-gateway` so the name resolves.
- **`PGMCP_ALLOWED_HOSTS` is filled in to match `--http-port`.** Streamable HTTP validates
  the `Host` header against it (DNS-rebinding protection); a mismatch surfaces as HTTP 421.
  If you change the published port later, change this too.

The port is published on `127.0.0.1` only, and the container runs read-only with
`cap_drop: ALL`. **Do not move that endpoint onto a shared network** — it has no
authentication and holds your database credentials.

### Doing it by hand

`env.example` and `mcp.json.example` show what `init` generates, if you would rather
write the files yourself. The one thing to get right in `.mcp.json` for a stdio install
is `cwd`: it must be **the project**, because that is what makes the server read that
project's `.env` and no other.

### Trying it against a throwaway database

This repo's own `docker-compose.yml` can start a scratch PostgreSQL:

```bash
docker compose --profile demo up -d
```

## Usage notes for the agent

Pass values separately instead of formatting them into SQL:

```
pg_query(sql="SELECT * FROM orders WHERE customer_id = %s AND created_at > %s",
         params=[42, "2026-01-01"])
```

Results are capped at `PGMCP_MAX_ROWS` (default 500) and long values at
`PGMCP_MAX_FIELD_CHARS`; the response says `truncated: true` when it hit the cap, so
page with `LIMIT`/`OFFSET`. Every statement is bounded by
`PGMCP_STATEMENT_TIMEOUT_MS` (default 30s).

`pg_execute` runs a whole batch in one transaction — if statement 3 fails, 1 and 2 roll
back with it. Pass `autocommit=true` only for statements Postgres refuses inside a
transaction (`CREATE INDEX CONCURRENTLY`, `VACUUM`).

## Development

```bash
pip install -e ".[dev]"
pytest                  # unit tests only — no database needed
ruff check .
```

The integration tests need a live PostgreSQL. A throwaway one:

```bash
docker run -d --name pgmcp-test -p 55432:5432 \
  -e POSTGRES_USER=testuser -e POSTGRES_PASSWORD=testpass -e POSTGRES_DB=testdb \
  postgres:17-alpine

pytest -m integration    # ~40s; spawns the server as a subprocess for the protocol tests
```

Override the target with `PGMCP_TEST_HOST`, `PGMCP_TEST_PORT`, `PGMCP_TEST_USER`,
`PGMCP_TEST_PASSWORD`, `PGMCP_TEST_DATABASE`. Everything is created in a dedicated
schema and dropped afterwards — still, never point them at real data.

Three layers, deliberately:

- `tests/test_safety.py`, `tests/test_config.py` — pure logic, no I/O.
- `tests/test_integration.py` — real SQL against a real server (this is where the
  read-only guarantee is actually proven, by watching PostgreSQL reject the write).
- `tests/test_mcp_protocol.py` — the server as a subprocess, driven over MCP, which
  is the only layer that catches wire-format and lifespan wiring mistakes.

### Platform note

On Windows, psycopg's async mode cannot run on the default `ProactorEventLoop`. The
server switches the policy to the selector loop at startup (`compat.py`), so nothing
extra is needed — but if you embed `build_server()` in your own process, call
`ensure_compatible_event_loop_policy()` before your event loop is created. Linux and
macOS, including every Docker deployment, are unaffected.

## Configuration reference

See `env.example` — every variable is listed there with its default.