DEV Community

Cover image for Claude Code, Beyond the Prompt — Part 4: Your First MCP Server (Give Claude Safe Hands on Your Own Tools)
Giulio D'Erme
Giulio D'Erme

Posted on • Edited on

Claude Code, Beyond the Prompt — Part 4: Your First MCP Server (Give Claude Safe Hands on Your Own Tools)

Security nuances of read-only database tools

Part 4 of Claude Code, Beyond the Prompt — patterns from running a live automated trading system on Claude Code. Part 3: slash commands and skills.


Here's a workflow you've definitely done:

You ask Claude what's wrong with production. It can't see production. So you SSH in, run a command, copy the output, paste it back into the chat. Claude reads it, asks for another command. You run that, copy, paste. Twenty minutes of you being a very expensive copy-paste buffer between Claude and your own systems.

There's a better way, and it's the single biggest capability jump in this series. It's called MCP — the Model Context Protocol — and it lets you give Claude its own tools: functions it can call directly to reach your systems, on your terms.

This is where "chat in a terminal" becomes "operational interface." Let me show you the smallest possible version, then how I scaled it to run real infrastructure.

What MCP actually is

Strip away the acronym. An MCP server is a small program that exposes a set of tools — named functions with typed inputs — that Claude can call. You write the functions. You decide exactly what they do and, more importantly, what they can't do. Claude calls them like any other tool; the results come back as structured data, not a wall of pasted text.

That's the whole model. Instead of you being the bridge between Claude and your database, you build a query_database tool once, and Claude uses it directly — but only in the ways you allowed.

The smallest useful MCP server

Here's a read-only tool that lets Claude run safe SELECT queries against a database. In Python, using the official SDK's FastMCP helper (check the current MCP docs for exact syntax — the shape is what matters):

from mcp.server.fastmcp import FastMCP
import psycopg  # psycopg 3

mcp = FastMCP("my-tools")

# The boundary is the ROLE: claude_readonly holds no INSERT/UPDATE/DELETE/DDL
# grant anywhere, so the database physically refuses a write. (A read-only
# *transaction* flag alone is reversible — grants are the wall.)
RO_DSN = "postgresql://claude_readonly:***@localhost/appdb"

@mcp.tool()
def db_query_ro(sql: str) -> str:
    """Run a read-only SQL query. Returns up to 500 rows."""
    with psycopg.connect(RO_DSN) as conn:
        conn.read_only = True                            # belt (not the wall)
        with conn.cursor() as cur:
            cur.execute("SET statement_timeout = '5s'")  # kill runaway queries
            cur.execute(sql)              # a write raises here — role has no grant
            return format_rows(cur.fetchmany(500))       # hard row cap

if __name__ == "__main__":
    mcp.run()
Enter fullscreen mode Exit fullscreen mode

Register it with Claude Code (via claude mcp add or your settings file), and now Claude has a db_query_ro tool. Ask "how many users signed up yesterday?" and it writes the query, calls the tool, and reads back 500 rows max. And if the model ever emits a write — directly, or smuggled inside a SELECT 1; DELETE FROM users — the read-only role makes Postgres refuse it. The safety isn't the tool guessing; it's the grant.

The boundary here is the role, and it has to be grants-based: claude_readonly holds no INSERT/UPDATE/DELETE/DDL privilege anywhere. Be careful not to fake it with a read-only transaction flag alone (default_transaction_read_only) — that's session-scoped and reversible, so an injected query can SET transaction_read_only = off and write straight through it. The row limit and statement timeout are the other two lines. Notice what's not there — any string-matching on the SQL. A prefix check like sql.startswith("select") looks like a guard but is only a suggestion: SELECT 1; DROP TABLE users sails straight past it, and so does a CTE that hides a DELETE ... RETURNING. And prove the wall exists: in CI, connect as the tool's role, attempt a write, and assert it errors — a boundary you've never attacked is declared, not enforced.

Why this beats pasting output

  • No lossy human relay. You stop being the buffer. Claude gets the real data, structured, and can chain three queries in the time it took you to alt-tab once.
  • You define the blast radius. The tool can only do what you coded. A read-only query tool cannot drop a table no matter how the conversation goes. Compare that to handing over shell access and hoping.
  • It's auditable. Every tool call goes through your code, so you can log every single one. I know exactly what was run against my systems and when, because the server writes it down.
  • It's fast and cheap. A tool returns 500 tidy rows instead of you pasting a 5,000-line log. Fewer tokens, faster turns (Part 7 puts numbers on this).

How I scaled it

My main MCP server exposes around thirty tools, and the story of how it got there is the story of this whole series.

It started exactly like the pain at the top: I was the copy-paste bridge between Claude and my servers. So I built one tool — read-only database queries. Then another — tail a service's logs. Then: check if a service is alive. Search the code. Read a file (with path-traversal blocked so it can't wander off into /etc). List and comment on GitHub issues. Eventually: deploy a file, with the deploy tool itself enforcing backup-first, checksum-verify, restart, health-check.

Every tool is narrow, sandboxed, and logged. Read-only by default; the few tools that write are the ones I deliberately hardened. The result is that Claude can operate my infrastructure — triage a down service, check overnight state, ship a fix — through an interface where the interface itself enforces the safety rules. I'm not trusting the model to be careful. I'm trusting my code to not let it be careless.

That's a very different feeling from ssh root@server and hoping.

The design lessons that matter

If you build one MCP server, internalize these:

  1. Read-only by default. Most of what you want is observation — query, read, check status, tail logs. Ship those first. They're safe, they're 80% of the value, and nothing they do can hurt you. Add write/deploy tools later, one at a time, each hardened on purpose.

  2. The tool enforces the rules, not the prompt. Never rely on "I told Claude not to." Rely on the tool physically being unable to. A read-only database role — one with no write grants at all — is enforcement: Postgres refuses the write. A string check that looks for SELECT, or a reversible read-only transaction flag, is a polite request that a compound statement or a SET walks straight through. That gap — enforcement vs. suggestion — is the difference between a safety rule and a safety feature. Prove which one you have: attack it in CI.

  3. Narrow beats general. A db_query_ro tool with a row limit is better than a run_shell tool that can do anything. Every capability you don't expose is an incident you can't have. Resist the urge to build one god-tool.

  4. Log everything. An audited interface turns "I hope nothing weird happened" into "here's the exact list of what ran." When Claude has hands on real systems, that ledger is what lets you sleep.

  5. Return small, structured results. Cap rows. Truncate logs. Return the 20 lines that matter, not the 5,000 that don't. Your tools are also your token budget.

Going deeper: the full production hardening of this tool — grants-based roles, read-scope via views, ALTER DEFAULT PRIVILEGES, a CI negative control, and an OS sandbox — is in the companion deep dive: Hardening an MCP Database Tool.

Don't reinvent what exists

You don't have to build everything. There's a growing ecosystem of ready-made MCP servers — for Postgres administration, for GitHub, for filesystems, for dozens of SaaS tools. I run an off-the-shelf Postgres-monitoring server alongside my custom one, because someone already built those 30 DBA tools better than I would have.

The pattern that scales: compose ready-made servers for the generic stuff, build custom servers for what's specific to you. Your custom server is where your system's unique shape lives — the tools that know your deploy recipe, your services, your conventions.

What you're really getting

The honest version: MCP is where Claude stops being an advisor you relay for and becomes something that can act on your systems directly — inside a fence you built. On a good setup, "check whether the payment service is healthy and show me the last errors" is one sentence, and Claude just does it, safely, in seconds.

It's also the most over-buildable thing in this series. So don't over-build. One read-only tool that removes your most annoying copy-paste loop is a complete, valuable first MCP server. Ship that. Add the next tool when a real task demands it.

Next

Your MCP tools let Claude reach your systems. But there's still one place it's oddly clumsy: finding the right code in your own repository. It falls back to grep, guesses keywords, and burns tokens spelunking through files. Part 5: semantic code search — why I stopped letting Claude grep, and the RAG setup I gave it instead.


Part 4 of a series on running real systems on Claude Code. The deeper retrieval research is open source — see RE-call. Part 5 is next.

Top comments (26)

Collapse
 
jugeni profile image
Mike Czerwinski

"The tool physically being unable to, not the model being told not to" is the whole argument and you back it with the exact failure that proves it: needsApproval silently ignored because execute-less browser tools never run server-side, so the framework's gate quietly didn't apply. That's a good concrete example of why a safety rule living in a prompt or a framework flag isn't a safety feature until something structural enforces it, in your case the database role having no write grant at all rather than a read-only transaction flag that's reversible. The row cap and statement timeout are the unglamorous parts that actually matter in production and most writeups skip them. One thing worth a follow-up post on its own: you mention thirty tools scaled from one, narrow and sandboxed and logged. What does the audit log actually get used for day to day, do you review it proactively or only pull it when something looks wrong after the fact?

Collapse
 
gde03 profile image
Giulio D'Erme

Your needsApproval case is the same trap: a flag that never runs server-side is declared, not enforced, like a read-only transaction flag (reversible) vs a missing write grant (structural).

The log: honestly, mostly reactive, I pull it when something looks wrong. A log you only open after a break is forensics, not prevention.

What scales is asserting on it, not reading it. Real one: an alert fires if a deploy tool runs with no matching merged PR on main. It caught a deploy of stale code, the change hadn't been pushed first, so the recipe shipped an old version. I'd never have caught that reading logs by hand.

"I'll review it" is a wish; an alert on it is a mechanism. Its own post, adding it to the hardening follow-up.

Collapse
 
jugeni profile image
Mike Czerwinski

The deploy-tool-with-no-matching-merged-PR alert is the whole argument in one example, and it's worth naming why it works. It's a check on a relation, deployed artifact bound to merged source, not a check on either object alone. That's exactly why reading either log by hand never catches it: both logs look fine individually, the lie lives in the mismatch between them. "A log you only open after a break is forensics, not prevention" is the keeper line, and asserting-on-it is what turns the log from a record into a gate.

The extension worth having: your alert fires because the relation is mechanically derivable, git SHA on main versus deployed SHA, no judgment in the loop. The alerts you can't write yet are the ones where the relation needs judgment, "did this deploy correspond to an intended change." You actually got intent for free here because a merged PR is a checkable proxy for intent-to-ship. That's the portable move, not the specific alert: manufacture a checkable proxy for the thing you want to assert. Where there's a proxy (merged PR stands in for intent), you get prevention. Where there isn't, you're back to forensics no matter how loudly you log. So the design question that scales past this one alert is: for each thing I currently only find out in the postmortem, what's the cheapest artifact I can require up front that a real version of it would produce and a fake one wouldn't. That's the same receipt-not-assertion line, pointed at your own deploy path.

Thread Thread
 
gde03 profile image
Giulio D'Erme

Yes, "a check on a relation, not either object" is the exact framing, and it's why hand-review never catches it: both logs are individually clean, the lie only lives in the join. Stealing that.

And you named the real lesson better than I did: the alert exists only because a merged PR is a cheap, checkable receipt for intent-to-ship. I got intent for free; the portable move is manufacturing that receipt on purpose.

Concrete version from earlier in this series, the collector-going-silent problem. "Is this feed alive" was pure forensics until I manufactured a receipt: assert on max(created_at) against expected cadence. A live feed emits a fresh timestamp; a dead one can't fake one. Same move as the PR, a real X produces an artifact a fake X can't.

Where it gets honest is your last line. Some things have no cheap receipt: "did this fill reflect real edge or noise" produces no up-front artifact — it only resolves with n, after the fact. No amount of logging makes that a gate; calling it one is theater. So I'd add one question ahead of yours: not just "what receipt can I require," but "does a cheap receipt exist here at all", because where it doesn't, forensics is the honest answer, and pretending otherwise is declared-not-enforced, one level up.

Thread Thread
 
jugeni profile image
Mike Czerwinski

"Does a cheap receipt exist here at all" is the question that should come first, agreed, but I'd split your no-receipt case in two before accepting it as pure forensics. Your own collector-silent example already shows why: is-this-feed-alive had no receipt, then you manufactured one, max(created_at) against expected cadence. That receipt didn't exist at the single-event level, it only exists once you're willing to look at a window. Same move might save "did this fill reflect real edge or noise": no single fill produces a receipt, but a distribution of fills over n does, expected fill-rate against realized fill-rate, deviation flagged after enough trials to mean something.

That's not the same as your PR-merge receipt, which resolves instantly and mechanically. Call it a delayed gate instead, mechanical, but only after aggregation, not after a human reads a log. Worth keeping as a third bucket next to gate and forensics, because "no receipt exists" and "no receipt exists yet, at n=1" point at different engineering responses. The first says stop looking for a mechanism. The second says the mechanism needs a sample size, not a person.

Where I'd agree completely: some things really don't clear even the aggregate bar. "Is this the right call" doesn't converge with more trials, it converges with outcomes that haven't happened yet. That one's forensics all the way down, and no amount of n fixes it.

Thread Thread
 
gde03 profile image
Giulio D'Erme

Fully agreed, and my own example convicts me: I called the fill case forensics right after describing the collector fix, which is the same aggregate receipt. max(created_at) over a window, and realized versus expected fill rate over n, are one move: no receipt at the event, a receipt at the distribution.

Three buckets, an engineering split:
Instant gate: mechanical at n=1 (merged PR versus deployed SHA).
Aggregate gate: mechanical, but only after n. My maker experiments gate on realized markout over n≥30 fills, no single fill tells you, the distribution does. Your line is the keeper: "no receipt" says stop looking for a mechanism, "no receipt at n=1" says it needs a sample size, not a person.
Forensics: never converges.

The middle bucket has its own failure mode: the receipt was always computable, I just wasn't looking at the window until it bit me, and firing before n is significant is a faster way to be wrong. The work there is the stopping rule, not a wall or a person.

On the floor, full agreement: n can't help because there's nothing fixed to converge to, just a mean that keeps moving, or a counterfactual only one branch ever reveals.

Thread Thread
 
jugeni profile image
Mike Czerwinski

"The middle bucket's failure mode is the stopping rule, not a wall or a person" is the sharper way to say what I was reaching for. A wall implies the receipt was never there. A stopping rule implies it was computable the whole time and you just hadn't waited long enough to trust it, which is a scheduling problem, not an epistemics problem.

Which makes the real design question for aggregate gates not "does a receipt exist" but "what's the cheapest n that makes the receipt honest." Fire before that n and you get a confident answer to a question the data hasn't earned yet, exactly the failure you flagged in your own fill case. Fire well past it and you've paid for certainty nobody needed, sitting on a signal that was already reliable a hundred trials ago. Neither number is obvious in advance, which is probably why most systems skip the aggregate bucket entirely and jump straight from instant gate to forensics, the stopping rule is the part nobody wants to sit down and compute.

Collapse
 
nazar-boyko profile image
Nazar Boyko

Funny tension in the sample: lesson two says the tool enforces the rule, not the prompt, but stripped.startswith("select") is still a vibes check. SELECT 1; DROP TABLE users starts with select. So does WITH x AS (DELETE FROM users RETURNING *) SELECT * FROM x, on Postgres. String prefix matching on SQL is exactly the class of guard that looks like enforcement and behaves like a suggestion, which is the thing the rest of the post is arguing against. You get to the real version by doing what you and Mads landed on further down: a read-only role, plus a read-only transaction, plus a statement timeout. Then the database refuses the write and the prefix check is just a nice error message instead of the thing standing between Claude and your users table. Might be worth putting that in the code sample rather than the comments, since the sample is what people will copy.

Collapse
 
gde03 profile image
Giulio D'Erme

You're right, and it's the most important comment on this post — so I'm fixing the sample, not just the thread.

The prefix check is exactly the thing the post argues against, wearing a costume. SELECT 1; DROP TABLE users passes startswith("select"); so does a CTE hiding a DELETE ... RETURNING. String-matching SQL is a suggestion with a startswith on it. I wrote "the tool enforces the rule, not the prompt" and then illustrated it with a rule the tool doesn't actually enforce. Fair catch.

The real version is the one you and Mads landed on up-thread: a read-only role (Postgres refuses the write), inside a read-only transaction, with a statement timeout. Then the database is the boundary, and the string check — if it survives at all — is just a friendlier error message, not the thing standing between the model and the users table.

Updating the code sample in the post now, since you're right that the sample is what people copy, not the caveats under it. This is the good kind of comment — thanks.

Collapse
 
alexshev profile image
Alex Shev

The phrase safe hands is doing a lot of work here. MCP gives the agent reach, but the server design decides what kind of reach it gets. I would rather expose a few narrow, well-audited actions than a broad tool surface that depends on the model choosing restraint every time.

Collapse
 
gde03 profile image
Giulio D'Erme

Agreed. The tell for me: if a tool's description says "the model should be careful to…", the boundary is in the wrong place. It belongs in the tool, where it can't be talked out of it. Narrow tools cost you more small tools, but a wall beats trusting restraint every time.

Collapse
 
alexshev profile image
Alex Shev

Exactly. If the safety instruction lives only in prose, it is competing with every future prompt and shortcut. The tool boundary is where that decision becomes real. I also like the small-tool cost framing: it is annoying upfront, but it makes the risky path harder to accidentally invent later.

Collapse
 
topstar_ai profile image
Luis Cruz

Great tutorial. Building an MCP server is a great way to move from simple prompt interactions to structured, tool-enabled AI workflows. Giving models controlled access to your own services through well-defined interfaces is much more scalable than embedding everything in prompts.

I also like the emphasis on safe tool access. Clear permissions, validation, and predictable interfaces are what make MCP practical for production systems—not just more powerful. As the ecosystem grows, well-designed MCP servers will likely become as important as APIs are today. Great walkthrough!

Collapse
 
gde03 profile image
Giulio D'Erme

Thanks — and you've put your finger on the real bet: a well-designed MCP server is basically an API whose caller happens to be a model instead of another program. Same discipline — clear contracts, validation, least privilege — except the caller is creative in ways an API client never is. That's exactly why "the tool enforces the rule, not the prompt" matters more here than in a normal API: you're not defending against bugs, you're defending against improvisation.

Completely agree on the "APIs of the AI era" framing. Worth being upfront about where the series is, though: this piece is deliberately the on-ramp — concepts for new and intermediate readers building their first server. I kept the depth out on purpose so it doesn't scare off the people who most need to ship tool #1. It gets more detailed as the series advances.

One teaser since you clearly get it: my setup isn't only Claude calling tools. I run a fine-tuned Qwen-13B as Claude's assistant, reachable over the same MCP layer — so Claude can hand the cheap, high-volume work down to a local model instead of doing everything itself. More on that later in the series.

Collapse
 
vinimabreu profile image
Vinicius Pereira

One Postgres subtlety worth pinning down here: it matters HOW the role is read-only. If it's done via default_transaction_read_only, that's still a suggestion, the session can just SET transaction_read_only = off and write, so a prompt-injected query walks straight through the "wall". It only becomes physical when it's grants-based: a role that simply holds no INSERT/UPDATE/DDL privilege anywhere. And the cheap way to know which one you have is a negative control in CI: connect as the tool's role, attempt a write, require the error. A boundary you have never attacked is declared, not enforced.

Collapse
 
gde03 profile image
Giulio D'Erme

Correct, and it's the sharpest cut in the thread. conn.read_only = True sets the transaction flag — session-scoped and reversible: a query can SET transaction_read_only = off and write straight through it. In my sample that line is belt, not wall. The wall is the DSN role — claude_readonly holding no INSERT/UPDATE/DDL grant anywhere. With only the transaction flag, an injected query owns you.

And the negative control is what people skip: in CI, connect as the tool's role, attempt a write, assert it errors. Same discipline as the rest of the series — a boundary you've never attacked is declared, not enforced. Adding that test to the sample. This is the comment that makes it actually correct.

Collapse
 
vinimabreu profile image
Vinicius Pereira

Glad it lands. One more brick while you're editing the sample: grants are point-in-time. GRANT SELECT ON ALL TABLES covers the tables that exist today; a table created next month is born outside the wall until someone remembers it. ALTER DEFAULT PRIVILEGES on the schema closes that hole, and the same negative control catches it if CI creates a scratch table first and then attempts the write as the tool's role.

Thread Thread
 
gde03 profile image
Giulio D'Erme

Right, grants are a snapshot; ALTER DEFAULT PRIVILEGES makes it durable. Precise version: a new table is born with the role holding no privileges, so the write-wall still holds by default, what actually breaks is the read side, plus the posture is now hand-maintained (i.e. forgettable). And the negative control only counts if it hits a fresh object: CI CREATEs a scratch table, then attempts the write as the tool's role and requires the error.

Honestly, this thread has outgrown the post, and that's a good problem. Part 4 is the beginner on-ramp, so I'm keeping the sample deliberately minimal instead of turning it into a Postgres-hardening tutorial; that depth would scare off exactly the people shipping their first server. So I'm collecting these (grant durability, default privileges, fresh-object CI) into a dedicated follow-up, "Hardening an MCP database tool", where they get the room they deserve. Thanks for stress-testing it here in the meantime.

Collapse
 
goldbean profile image
GoldBean

Great series on Claude Code + MCP! If anyone wants to try a production MCP server with real AI endpoints (ERNIE-4.5, DeepSeek-R1, OCR, NLP), check out GoldBean — it wraps 67 Baidu AI endpoints behind a single MCP server with x402 micropayments. npx goldbean-mcp to get started.

Collapse
 
alexshev profile image
Alex Shev

The phrase safe hands is the right lens for MCP. A tool is not just extra capability; it is a new action boundary. The server should make allowed operations, inputs, side effects, and failure states obvious enough that the agent cannot turn a helper into an uncontrolled mutation path.

Collapse
 
ahmetozel profile image
Ahmet Özel

Read-only SQL plus a row limit and timeout is a good minimal boundary. In production I would also scope the database identity per tool and log the normalized query plus result schema, because a SELECT-only tool can still expose more data than the current task should see.

Collapse
 
gde03 profile image
Giulio D'Erme

Right — different axis. Read-only role, row limit, timeout stop writes. You're on read scope: a SELECT-only tool that can reach the whole schema still hands the model your users table when it only needed order counts.

Fix it in the DB, not the tool code: point each tool at a purpose-built read-only view (only the columns that job needs, no PII). Per-tool identity enforces it; the view is the scope. Filtering results inside the tool would just be the string-check mistake one level up.

And +1 on logging the normalized query + result schema — write-protection says the tool couldn't do damage; the log is the only thing that tells you what it actually read.

Collapse
 
goldbean profile image
GoldBean

Great series on Claude Code + MCP! If anyone wants to try a production MCP server with real AI endpoints (ERNIE-4.5, DeepSeek-R1, OCR, NLP), check out GoldBean — it wraps 67 Baidu AI endpoints behind a single MCP server with x402 micropayments. npx goldbean-mcp to get started.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.