# Give a data team Claude access to Postgres, without giving anyone the password

## Problem

Analysts want to ask questions of the warehouse in natural language. The only
way to do that today is a connection string in `claude_desktop_config.json` on
every laptop.

That string is unrotatable in practice, unauditable entirely, sitting in
plaintext on a dozen machines — and offboarding one person means rotating the
password and updating everyone else. Meanwhile nothing bounds what those
sessions do: twelve agents deciding at 9am to scan `orders` is a load pattern
the read replica has never seen.

## Fix

### 1. Create the role your access boundary will actually be

Do this first, in the database, because it is the enforcement nothing downstream
can undo. Grant exactly what analysts should reach and nothing else:

```sql
CREATE ROLE nthbouncer_ro LOGIN PASSWORD '<generate one>';
GRANT CONNECT ON DATABASE analytics TO nthbouncer_ro;
GRANT USAGE ON SCHEMA public TO nthbouncer_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO nthbouncer_ro;
ALTER ROLE nthbouncer_ro SET default_transaction_read_only = on;
ALTER ROLE nthbouncer_ro SET statement_timeout = '30s';
```

Narrow it further if you have tables analysts should not see — grant per schema
or per table instead of `ALL TABLES`. There is no allowlist to configure in
nthbouncer, deliberately: your grants are the boundary, and a second list would
only give you two answers to the same question.

The role must use `scram-sha-256` password encryption. `md5` is refused.

### 2. Create the pool

**Pools → New pool → Postgres database.** Backend is the first question and it
cannot be changed later.

Then fill in the connection: host, port, database, and the role above. The
database must be reachable from the public internet over TLS — a public read
replica is the usual answer if your primary is not.

### 3. Test the connection before you tell anyone about it

Press **Test connection**. It opens a real connection and tells you which stage
failed if one does.

Pay attention to one result in particular: a **successful** connection that sees
**zero tables**. That means the role can log in but was never granted `USAGE` on
the schema, and every query an analyst runs will report that the relation does
not exist. It is the most common mistake in this whole setup, and the preflight
is there so you find it now rather than in a support thread on Monday.

### 4. Set the concurrency limit to what the replica can take

This is the part a connection string cannot do for you. On **Concurrency**, set
the pool's limit to the number of simultaneous analytical queries the database
should ever run. Twelve agents will still queue behind it rather than arriving
at once.

Note what a connection pooler would *not* have solved here: multiplexing twelve
connections down to four still runs twelve expensive queries. The limit has to
be on the work.

While you are there, lower **max rows** if analysts mostly want summaries — a
smaller cap is cheaper for you and better for the agent, whose context it fills.

### 5. Connect the team's agents

Each analyst connects once as in [Agents](/docs/agents) and approves a grant
scoped to this pool. From then on their agent has `pool_schema`,
`pool_query`, `get_query_result`, and `pool_count_rows`.

Nobody is handed a database credential at any point. What an analyst holds is a
grant you approved, which you can revoke by itself.

### 6. Tell them how to ask expensive questions

The one thing worth teaching: for anything analytical, pass `wait_ms: 0`. The
query starts, the agent gets a handle immediately, and it reads the result on a
later turn.

Every MCP client enforces a tool-call timeout, so a synchronous query caps out
around a minute — which is to say, not analytics. This is how you get past that.

## Verify

- **Logs** shows one row per query: the statement, duration, rows returned, and
  **which key ran it**. That last column is the answer to "who ran what against
  production," and it exists because the pool knows who is asking.
- Try a write from an agent (`update orders set total = 0`). It comes back
  refused, and the attempt appears in Logs as a denied statement with the
  statement text kept. That is the audit trail working, not a bug.
- **Metrics** shows queries queueing behind the concurrency limit when several
  analysts are busy — the replica staying inside the budget you set.

## Offboarding

Revoke that person's grant. That is the whole procedure.

No password rotation, no updating eleven laptops, and no wondering whether an
old connection string is still in someone's config file.
