Claude Code, Beyond the Prompt — Hardening an MCP Database Tool (Part 4 Deep Dive) A developer documented seven layers of hardening for a Model Context Protocol (MCP) database tool, moving from declared rules to enforced protections. The approach replaces reversible settings like read-only transactions with structural enforcement via database roles and grants, adds statement timeouts and row caps, and scopes read access per tool using views. The work addresses vulnerabilities exposed by readers of a previous tutorial on building a minimal MCP server. A deep-dive companion to Part 4: your first MCP server. Part of Claude Code, Beyond the Prompt. Part 4 https://dev.to/gde03/claude-code-beyond-the-prompt-part-4-your-first-mcp-server-give-claude-safe-hands-on-your-own-b8p 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. 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. The subtlety that trips most people up: how the tool is read-only decides whether it's a wall or a suggestion. SET default transaction read only = on , or psycopg's conn.read only = True is reversible. The same session can SET transaction read only = off and write. A prompt-injected query does exactly that. It's a flag, and flags flip.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. 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. 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: 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. 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. 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: python 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." 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. 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 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. 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: