{"slug": "claude-code-beyond-the-prompt-hardening-an-mcp-database-tool-part-4-deep-dive", "title": "Claude Code, Beyond the Prompt — Hardening an MCP Database Tool (Part 4 Deep Dive)", "summary": "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.", "body_md": "*A deep-dive companion to Part 4: your first MCP server. Part of Claude Code, Beyond the Prompt.*\n\n[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.\"\n\nThis 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.\n\nOne principle runs through all of it.\n\nEverything below is the same move repeated: take a rule that's *declared* and replace it with a rule that's *enforced*.\n\nA prompt that says \"only read, never write\" is a declaration. A framework flag like `needsApproval`\n\nthat never actually runs server-side is a declaration. `sql.strip().startswith(\"select\")`\n\nis a declaration — `SELECT 1; DROP TABLE users`\n\nwalks right past it. Even a read-only *transaction* is a declaration, because a session can turn it back off.\n\nA 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.\n\nSeven layers, each turning one declaration into enforcement.\n\nThe subtlety that trips most people up: *how* the tool is read-only decides whether it's a wall or a suggestion.\n\n`SET default_transaction_read_only = on`\n\n, or psycopg's `conn.read_only = True`\n\n) is reversible. The same session can `SET transaction_read_only = off`\n\nand write. A prompt-injected query does exactly that. It's a flag, and flags flip.Set it up grants-first:\n\n```\n-- A role that structurally cannot write.\nCREATE ROLE claude_readonly LOGIN PASSWORD '***';\n\n-- Start from nothing, then add back only what's needed.\nREVOKE ALL ON SCHEMA app FROM claude_readonly;\nGRANT USAGE ON SCHEMA app TO claude_readonly;\nGRANT SELECT ON app.orders_summary TO claude_readonly;\n```\n\nKeep 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\")`\n\ncheck, or keep it purely as a friendlier error message. It is not, and never was, the boundary.\n\nMost walkthroughs stop at \"read-only\" and skip the two lines that keep it standing up in production:\n\n```\ncur.execute(\"SET statement_timeout = '5s'\")   # kill runaway queries\nrows = cur.fetchmany(500)                       # bound the result set\n```\n\nA **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`\n\n) so a stuck tool can't exhaust the pool.\n\nWrite protection answers \"can it damage data?\" It says nothing about \"can it *see* data it shouldn't?\" A `SELECT`\n\n-only role with reach across the whole schema will happily hand the model your entire `users`\n\ntable when the task only needed yesterday's order count.\n\nSo scope the *reads*, in the database, per tool:\n\n```\nCREATE VIEW app.orders_summary AS\n    SELECT order_id, status, created_at, total_cents   -- no customer_email, no address\n    FROM app.orders;\n\nGRANT SELECT ON app.orders_summary TO claude_readonly;\n```\n\nNow \"read scope\" is defined declaratively in the schema and enforced by the engine. Even a creative `SELECT`\n\ncan'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`\n\nmistake wearing a different hat — a check the model's output has to pass, instead of a wall it can't get through.\n\nHere's a hole that opens on its own, with nobody doing anything wrong. `GRANT SELECT ON ALL TABLES`\n\ncovers the tables that exist *today*. A table created next month is born with your read-only role holding no privileges on it at all.\n\nBe 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.\n\n`ALTER DEFAULT PRIVILEGES`\n\ncloses it:\n\n```\nALTER DEFAULT PRIVILEGES IN SCHEMA app\n    GRANT SELECT ON TABLES TO claude_readonly;\n```\n\nFuture tables in that schema are now readable-but-never-writable for the role, by default, without anyone remembering anything.\n\nYou 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.\n\nAnd 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:\n\n``` python\nimport psycopg, pytest\n\ndef test_tool_role_cannot_write(admin_dsn, tool_dsn):\n    # Create a brand-new table AFTER the grants were configured.\n    with psycopg.connect(admin_dsn, autocommit=True) as admin:\n        admin.execute(\"CREATE TABLE app.scratch_probe (id int)\")\n    try:\n        with psycopg.connect(tool_dsn) as ro, ro.cursor() as cur:\n            with pytest.raises(psycopg.errors.InsufficientPrivilege):\n                cur.execute(\"INSERT INTO app.scratch_probe VALUES (1)\")\n    finally:\n        with psycopg.connect(admin_dsn, autocommit=True) as admin:\n            admin.execute(\"DROP TABLE app.scratch_probe\")\n```\n\nIf 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.\"\n\nEverything 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.\n\nSo 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`\n\n, can't touch a service it was never given.\n\nThe 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.\n\nLog every call — the normalized query, the result schema, the row count. That's your audit trail, and you want it.\n\nBut 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.\n\nWhat scales isn't *reviewing* the log, it's *asserting* on it. Turn the log into alerts:\n\nA 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.\n\nGo 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.\n\nPart 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.\n\nAnd 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`\n\n, 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.\n\n*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:", "url": "https://wpnews.pro/news/claude-code-beyond-the-prompt-hardening-an-mcp-database-tool-part-4-deep-dive", "canonical_source": "https://dev.to/gde03/claude-code-beyond-the-prompt-hardening-an-mcp-database-tool-part-4-deep-dive-299m", "published_at": "2026-07-13 09:20:06+00:00", "updated_at": "2026-07-13 09:45:24.343485+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools", "artificial-intelligence", "large-language-models"], "entities": ["Claude Code", "MCP", "psycopg"], "alternates": {"html": "https://wpnews.pro/news/claude-code-beyond-the-prompt-hardening-an-mcp-database-tool-part-4-deep-dive", "markdown": "https://wpnews.pro/news/claude-code-beyond-the-prompt-hardening-an-mcp-database-tool-part-4-deep-dive.md", "text": "https://wpnews.pro/news/claude-code-beyond-the-prompt-hardening-an-mcp-database-tool-part-4-deep-dive.txt", "jsonld": "https://wpnews.pro/news/claude-code-beyond-the-prompt-hardening-an-mcp-database-tool-part-4-deep-dive.jsonld"}}