A deep-dive companion to Part 4: your first MCP server. Part of Claude Code, Beyond the Prompt.
Part 4 shipped a deliberately minimal MCP database tool — enough to give Claude safe, read-only hands on a database without turning the post into a security treatise. Then the comments did something better than any post could: readers took that sample apart and found every seam where "minimal and safe enough to learn on" isn't "safe enough for production."
This is the collected result. It's more advanced than Part 4 on purpose. If you haven't built your first MCP server yet, start there. If you have one and you're about to point it at a database that matters, this is the hardening pass.
One principle runs through all of it.
A boundary is only real if something structural enforces it
Everything below is the same move repeated: take a rule that's declared and replace it with a rule that's enforced.
A prompt that says "only read, never write" is a declaration. A framework flag like needsApproval that never actually runs server-side is a declaration. sql.strip().startswith("select") is a declaration — SELECT 1; DROP TABLE users walks right past it. Even a read-only transaction is a declaration, because a session can turn it back off.
A declaration asks nicely. An enforcement makes the dangerous thing impossible. The model doesn't have to be adversarial for this to matter — it just has to occasionally generate something dumb, at which point "I told it not to" and "it physically can't" are very different places to be standing.
Seven layers, each turning one declaration into enforcement.
1. Write protection that can't be turned off
The subtlety that trips most people up: how the tool is read-only decides whether it's a wall or a suggestion.
- A read-only transaction (
SET default_transaction_read_only = on, or psycopg'sconn.read_only = True) is reversible. The same session canSET transaction_read_only = offand write. A prompt-injected query does exactly that. It's a flag, and flags flip. - A read-only role — one that simply holds no INSERT / UPDATE / DELETE / DDL privilege anywhere — cannot write, because the privilege to write does not exist for it. There is nothing to flip. That's structural.
Set it up grants-first:
-- A role that structurally cannot write.
CREATE ROLE claude_readonly LOGIN PASSWORD '***';
-- Start from nothing, then add back only what's needed.
REVOKE ALL ON SCHEMA app FROM claude_readonly;
GRANT USAGE ON SCHEMA app TO claude_readonly;
GRANT SELECT ON app.orders_summary TO claude_readonly;
Keep the read-only transaction as well — belt and suspenders — but understand which is which: the grant is the wall, the transaction flag is the belt. And drop the startswith("select") check, or keep it purely as a friendlier error message. It is not, and never was, the boundary.
2. The unglamorous parts that actually matter
Most walkthroughs stop at "read-only" and skip the two lines that keep it standing up in production:
cur.execute("SET statement_timeout = '5s'") # kill runaway queries
rows = cur.fetchmany(500) # bound the result set
A statement timeout, because a model will eventually emit an accidental cross join that tries to read a billion rows. A row cap, because even a legitimate query can return far more than your context — or your memory — wants. Neither is exciting. Both are the difference between "a bad query is an error message" and "a bad query is an incident." Add a connection cap on the role too (ALTER ROLE claude_readonly CONNECTION LIMIT 4) so a stuck tool can't exhaust the pool.
3. Read scope, not just write protection
Write protection answers "can it damage data?" It says nothing about "can it see data it shouldn't?" A SELECT-only role with reach across the whole schema will happily hand the model your entire users table when the task only needed yesterday's order count.
So scope the reads, in the database, per tool:
- Give each tool its own role — per-tool identity, not one shared "readonly" god-role.
- Point it at purpose-built read-only views, not base tables. The view exposes exactly the columns that job needs, and nothing else.
CREATE VIEW app.orders_summary AS
SELECT order_id, status, created_at, total_cents -- no customer_email, no address
FROM app.orders;
GRANT SELECT ON app.orders_summary TO claude_readonly;
Now "read scope" is defined declaratively in the schema and enforced by the engine. Even a creative SELECT can't reach a column the view doesn't expose. Note what you're not doing: filtering results in the tool's code. Filtering in code is the startswith mistake wearing a different hat — a check the model's output has to pass, instead of a wall it can't get through.
4. Grants are point-in-time — make them durable
Here's a hole that opens on its own, with nobody doing anything wrong. GRANT SELECT ON ALL TABLES covers the tables that exist today. A table created next month is born with your read-only role holding no privileges on it at all.
Be precise about what that means: the new table is write-safe by default (no grant = no write, so the wall holds), but your read access silently breaks — and, worse, your posture is now hand-maintained. "Read-only, and it stays read-only" quietly becomes "read-only, as long as someone remembers to re-grant correctly on every new object." Hand-maintained means forgettable.
ALTER DEFAULT PRIVILEGES closes it:
ALTER DEFAULT PRIVILEGES IN SCHEMA app
GRANT SELECT ON TABLES TO claude_readonly;
Future tables in that schema are now readable-but-never-writable for the role, by default, without anyone remembering anything.
5. Prove it: a boundary you've never attacked is only declared
You can set all of this up perfectly and still not know it's enforced — because you've never tried to break it. The cheap, decisive check is a negative control in CI: connect as the tool's role, attempt a write, and require the error.
And it only means something if it attacks a fresh object — otherwise you're only proving today's tables are walled, which says nothing about next month's:
import psycopg, pytest
def test_tool_role_cannot_write(admin_dsn, tool_dsn):
# Create a brand-new table AFTER the grants were configured.
with psycopg.connect(admin_dsn, autocommit=True) as admin:
admin.execute("CREATE TABLE app.scratch_probe (id int)")
try:
with psycopg.connect(tool_dsn) as ro, ro.cursor() as cur:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
cur.execute("INSERT INTO app.scratch_probe VALUES (1)")
finally:
with psycopg.connect(admin_dsn, autocommit=True) as admin:
admin.execute("DROP TABLE app.scratch_probe")
If that test ever flips green-to-red, your read-only guarantee regressed and you find out in CI instead of in an incident. This is the highest-leverage thing in the whole post: it converts "I configured it read-only" into "read-only is verified on every commit."
6. Below the tool: the OS sandbox
Everything so far hardens the database boundary. But the tool is also a process, and a bug in any tool — not just the database one — shouldn't be able to reach past what that process was granted.
So run the whole MCP server as its own unprivileged Linux user: a locked-down sudoers allowlist (or none), a filesystem confined to its own directory, no ambient credentials to anything it doesn't use. Then even a compromised or buggy tool can't sudo, can't wander into /etc, can't touch a service it was never given.
The point is independence: the DB grants, the OS user, and the tool code each refuse the dangerous thing on their own. Defense in depth means the model hits a wall whichever layer it probes — and no single mistake removes all the walls at once.
7. After the fact: logging you actually use
Log every call — the normalized query, the result schema, the row count. That's your audit trail, and you want it.
But be honest about how it gets used, because this is where most "we have full audit logging" claims quietly fall apart: you will not proactively read it. Nobody reads a raw log line by line. A log you only open after something breaks is forensics — useful, but reactive by definition.
What scales isn't reviewing the log, it's asserting on it. Turn the log into alerts:
- a write-path tool firing outside its expected window,
- a deploy tool running with no matching merged PR,
- a call-volume spike that doesn't match any human activity.
A real one from my own setup: an alert fires if the deploy tool runs without a corresponding merged PR on the main branch. It caught a deploy of stale code once — the change hadn't been pushed first, so the recipe shipped an old version. I would never have caught that reading logs by hand; the assertion caught it in seconds. "I'll review the audit log" is a wish. An alert over the audit log is a mechanism.
The through-line
Go back through the seven layers and they're all one move: a declaration — a prompt, a flag, a string check, a point-in-time grant, an unverified config, an unread log — replaced with an enforcement that holds whether or not anyone is paying attention.
Part 4's minimal tool is the right place to start; you should not front-load all of this onto someone shipping their first server. But the day that tool points at a database that matters, this is the pass: a role that can't write, scoped to views that can't over-expose, kept durable by default privileges, proven by a negative control, boxed inside an OS sandbox, and watched by alerts instead of hope.
And credit where it's due — this post is basically what a good comment thread produces. The sharpest points here (read-only-transaction reversibility, ALTER DEFAULT PRIVILEGES, the fresh-object negative control, read-scope via views) came from readers taking the Part 4 sample apart in public. That's the good kind of internet.
A companion deep-dive to Part 4, part of Claude Code, Beyond the Prompt. The retrieval side of "safe hands" — hardening what an agent can *read from its own memory — is its own open-source project: RE-call.*
Top comments (0)