Building MCP Servers — Giving Agents Hands
It's a Thursday afternoon, and a backend engineer named Devin is watching his support team drown.
Every morning the same ritual plays out. A customer writes in: "My subscription says active but I can't access the dashboard." A support agent copies the email address, switches to the admin panel, looks up the account, switches to Stripe to check the last payment, switches to the logs to see the failed webhook, then switches to a Slack channel to ask Devin why the webhook failed. Four tools, six minutes, and the answer is almost always the same boring thing: a card expired and the retry hasn't fired yet.
Devin has Claude Code open on his other monitor. On a whim, he types: "Look up the account for this email, check its last three Stripe charges, and tell me why the dashboard is locked." Claude thinks for a moment and replies, honestly: I don't have access to your account database or your Stripe account. I can write you a script that would do this if you give me the connection details.
That sentence is the whole problem in one line. The model is brilliant. It can reason about expired cards, webhook retries, and subscription state better than half the team. But it has no hands. It cannot reach into the database. It cannot call Stripe. It is a genius locked in a room with a telephone but no arms — it can tell you exactly what to do, but it can't do anything itself.
By the end of that Thursday, Devin had built a small MCP server that exposed three tools — lookup_account, recent_charges, and access_status — wired it into Claude Code, and watched the same investigation that used to take six minutes and four tools collapse into a single sentence and a four-second answer. He didn't teach the model anything about subscriptions. He gave it hands.
This chapter is about building those hands.
What We're Building On
In Chapter 16 you learned what MCP is. As you saw there, the Model Context Protocol is the USB-C of AI tools — one open standard that lets any AI client talk to any data source through a server that exposes tools (actions), resources (readable data), and prompts (templates), all over JSON-RPC. You saw the N×M integration problem collapse to N+M. You saw a forty-five-line SQLite server. You learned the vocabulary.
This chapter assumes all of that and goes to the place Chapter 16 deliberately did not: production. A toy server that runs on your laptop and queries a sample database is a fifteen-minute exercise. A server that other people — or other agents — depend on, that touches real customer data, that survives a malformed request, a malicious one, and a 3 a.m. traffic spike, is an engineering artifact with a security model, a versioning strategy, and an error contract.
A go-kart and a road car both have four wheels, a steering wheel, and an engine. You can build a go-kart in a weekend and it will genuinely drive. But you cannot drive it on a highway, in the rain, with your kids in the back, for ten years. The difference isn't the idea — it's seatbelts, airbags, crumple zones, brake lights, a fuel gauge, a service manual, and a thousand things that only matter when something goes wrong. Chapter 16 built the go-kart so you'd understand the mechanism. This chapter builds the road car, because that's the one you actually ship.
Why does "give the agent a tool" turn out to be a security and reliability problem rather than just a coding problem? Because a tool is a hole you punch in the wall around your data, and you're handing the key to a non-deterministic system that a stranger can talk to. The model is persuadable. The user is untrusted. The data is precious. Every serious decision in this chapter falls out of those three facts.
The Shape of a Production Server
Before any code, hold the picture in your head. A real MCP server is not a script — it's a small service with a clear inside and outside.
ANATOMY OF A PRODUCTION MCP SERVER
==============================================================
UNTRUSTED SIDE TRUSTED SIDE
(agent + user) (your data)
| |
v |
+-----------+ |
| TRANSPORT | stdio (local) or |
| | HTTP+auth (remote) |
+-----+-----+ |
| |
v |
+-----------+ reject bad/oversized |
| AUTH | tokens here, early |
+-----+-----+ |
| |
v |
+-----------+ validate every arg |
| SCHEMA / | against the schema; |
| VALIDATE | refuse if it fails |
+-----+-----+ |
| |
v |
+-----------+ per-tool, per-caller |
| RATE LIMIT| budgets + quotas |
+-----+-----+ |
| |
v |
+-----------+ the actual work, |
| TOOL |---> least-privilege ---> [ DATA ]
| HANDLER | connection |
+-----+-----+ |
| |
v |
+-----------+ structured result OR |
| ERROR | a safe, typed error |
| SURFACE | (never a raw stack) |
+-----------+ |
Read that diagram top to bottom and you have the chapter's table of contents. Every layer between the agent and your data exists because something can go wrong at that layer. We'll build the happy path first, then install each guard.
Step One: Design the Tools Before You Write Them
The most common mistake beginners make is writing the server first. The server is the easy part — the SDK does most of it. The hard, valuable, irreversible part is deciding what tools to expose and what their shapes are. Tools are a public interface. Once an agent in the wild depends on get_user, you cannot quietly rename it to fetch_user without breaking that agent. Design here is load-bearing.
A good tool reads like a sentence the model would want to say. When the model is mid-reasoning and thinks "I need this account's recent charges," there should be a tool whose name and description match that thought so closely that the model picks it without hesitation. The model is your user. Design for its ergonomics.
Compare two designs for Devin's support problem.
BAD: ONE GOD-TOOL GOOD: THREE CLEAR VERBS
============================== ============================
lookup_account(email)
run_query(sql: string) -> account summary
"Run any SQL you want." recent_charges(account_id,
limit=3)
The model must know your -> last N charges
schema, write correct SQL,
and you must pray it never access_status(account_id)
writes DELETE. -> why dashboard locked
The god-tool feels powerful and is a trap. It forces the model to know your schema, leaks your table structure into every prompt, makes validation nearly impossible (any string is "valid" SQL), and turns a single prompt-injection into a DROP TABLE. The three-verb design constrains the surface to exactly the operations you intend, each independently validatable and rate-limitable.
If a tool's input is "arbitrary code in language X" — raw SQL, a shell command, a Python eval — you have not designed a tool. You have designed a remote-code-execution endpoint and pointed a gullible model at it. Sometimes that's genuinely what you want (a sandboxed code-runner is a legitimate, careful product). But it should be a deliberate, fenced decision, never the lazy default.
The most-installed third-party MCP servers in the registry almost never expose a raw-query tool. The popular Stripe, Linear, and GitHub servers expose dozens of narrow, named tools — create_invoice, list_issues, open_pull_request — each with a tight schema. That isn't an accident of taste. Narrow tools are the only ones a platform can safely let a stranger's agent call, because each one's blast radius is known in advance. Breadth of named tools beats depth of one powerful tool, every time you're exposing to the outside world.
Schema Design: The Model Reads Your Schemas
In a normal API, the schema is for machines — a validator that rejects bad input. In MCP, the schema is also a prompt. The model reads your parameter names, types, descriptions, and constraints to decide whether and how to call the tool. A vague schema produces a confused model; a precise one produces a confident, correct one.
WHAT THE MODEL ACTUALLY SEES
==============================================================
Tool: recent_charges
Description: List a customer's most recent Stripe
charges, newest first. Use this to diagnose
why a subscription appears unpaid.
Parameters:
account_id (string, required)
The internal account UUID, not the email.
Get it from lookup_account first.
limit (integer, 1-20, default 3)
How many charges to return.
Notice how much steering lives in plain English here. "Newest first" tells the model what order to expect. "Use this to diagnose..." tells it when to reach for the tool. "The internal account UUID, not the email" preempts the single most likely mistake — the model trying to pass the email it already has. "Get it from lookup_account first" wires two tools into a sequence without any code. The description field is the cheapest, highest-leverage place to improve a server's behavior.
Three schema rules that pay for themselves:
- Constrain aggressively. If
limitmust be 1–20, say so in the schema. The SDK will reject 5,000 before your handler ever runs, and the model will never even try it. - Name for the caller, not the database.
account_id, notacct_pk. The model has no idea what your primary key is called and shouldn't have to. - Make required fields truly required, optional fields truly optional with sane defaults. Every optional field with a default is one fewer thing the model can get wrong.
Step Two: The Server, From Toy to Tough
Here is Devin's lookup_account tool, written the way a production server actually looks. It's still the FastMCP shape you saw in Chapter 16 — but watch what's been added around the edges.
# support_server.py
from mcp.server.fastmcp import FastMCP
from dataclasses import dataclass
import os, time, logging
mcp = FastMCP("Support Tools")
log = logging.getLogger("support_server")
# Least-privilege: a READ-ONLY pool, scoped to the
# two tables these tools need. Never the admin role.
POOL = make_readonly_pool(os.environ["SUPPORT_DB_URL"])
@dataclass
class Account:
id: str
email: str
plan: str
status: str
@mcp.tool()
def lookup_account(email: str) -> dict:
"""Look up a customer account by email address.
Returns the account id, plan, and status. Use the
returned id with recent_charges or access_status.
"""
email = email.strip().lower()
if "@" not in email or len(email) > 254:
# Typed, safe error — not a stack trace.
return {"error": "invalid_email",
"message": "Provide one valid email."}
row = POOL.fetchone(
"SELECT id, email, plan, status "
"FROM accounts WHERE email = %s",
(email,),
)
if row is None:
return {"error": "not_found",
"message": "No account for that email."}
return {
"id": row["id"],
"email": row["email"],
"plan": row["plan"],
"status": row["status"],
}Walk the differences from the Chapter 16 toy:
- The connection is least-privilege.
make_readonly_poolreturns a connection bound to a database role that physically cannot write. Even if every other defense fails — even if the model is tricked into trying — there is no SQL it can send that deletes a row, because the role lacks the grant. This is the single most important habit in the chapter. Defend at the data layer, not just the code layer. - Input is validated before the query. The email is trimmed, lowercased, and sanity-checked. Parameterized queries (
%s) prevent injection; the length check prevents someone stuffing a megabyte into a query. - Errors are typed values, not exceptions. When the email is bad or the account is missing, the tool returns a small structured object the model can read and react to ("I should ask the user to double-check the email"). It does not throw, and it never leaks a database error message — which might contain table names, column names, or worse.
That last point deserves its own section, because error design is where amateur servers and professional ones diverge most sharply.
Error Surfaces: What the Model Sees When Things Break
When a tool fails, something travels back up the wire to the model. What you put in that something is a design decision with security, usability, and debugging consequences all at once.
There are three audiences for a failure, and they want different things:
THREE AUDIENCES, THREE NEEDS
==============================================================
THE MODEL wants: a short, machine-actionable
reason it can reason about and maybe
recover from. ("not_found" -> ask user)
THE END USER wants: a human sentence that doesn't
expose internals. ("No account found
for that email.")
YOU (ON CALL) want: the full stack trace, the query,
the timing — in your LOGS, never on
the wire.
The discipline is simple to state and easy to forget under deadline: rich detail to your logs, thin safe truth to the model, never the raw exception to either. A leaked stack trace is both a debugging crutch you'll regret and an information-disclosure vulnerability you'll get audited for.
Think about a good restaurant when a dish goes wrong in the kitchen. The waiter doesn't march out and announce "the sous-chef dropped the salmon and the walk-in fridge is six degrees too warm." That's the kitchen's information — it goes on the incident log by the pass. What you get at the table is calm and useful: "That dish isn't available tonight, may I suggest the trout?" Your error surface is the waiter. Your logs are the kitchen. Never let the kitchen shout across the dining room.
A practical taxonomy of errors, and how each should surface:
- User errors (bad email, missing field): typed error the model can fix by asking the user. Cheap, safe, expected. Not even worth logging at warning level.
- Not-found (valid request, no data): not really an error — a legitimate empty result. Return it as data, not as failure, or the model may retry pointlessly.
- Transient errors (database busy, timeout): tell the model it's retryable. A flag like
{"error": "temporarily_unavailable", "retryable": true}lets a well-built agent back off and try again instead of giving up or, worse, hammering. - Internal errors (a bug, a null where you assumed a value): log the full detail, return a generic
{"error": "internal", "message": "Something went wrong on our end."}. The model learns nothing exploitable; you learn everything from the logs.
Authentication: Who Is Allowed to Hold the Hand?
A local stdio server, as Chapter 16 noted, runs with your own permissions — it's no more dangerous than a script you'd run yourself. The moment a server goes remote, over HTTP, reachable by anyone who knows the URL, authentication stops being optional and becomes the wall the whole castle stands behind.
The matured MCP spec standardizes on OAuth 2.1 for remote servers, with bearer tokens carried in the standard Authorization header. The flow is the one you already know from "Sign in with Google" — the difference is that the client at the end is an agent, and the thing being authorized is tool access, not page views.
REMOTE MCP AUTH, IN ONE PICTURE
==============================================================
AGENT/CLIENT AUTH SERVER MCP SERVER
| | |
|-- want access --->| |
| | |
|<-- token (scoped, | |
| short-lived) --| |
| | |
|---- call tool, Authorization: ------>|
| Bearer <token> ----------------->|
| | |
| | verify sig, |
| | check scopes, |
| | check expiry |
| | |
|<------ result, OR 401/403 -----------|
Three properties make a token safe, and you should be able to name all three about every token your server accepts:
- Scoped. A token grants specific tools or specific data, not the whole server. Devin's support token might allow
lookup_accountandrecent_chargesbut never a hypotheticalissue_refund. Scopes are how you give an agent exactly the hand it needs and no more. - Short-lived. A token that lives an hour limits the damage of a leak to an hour. Long-lived tokens are landmines waiting in a log file or a screenshot.
- Verifiable without a database round-trip. Signed tokens (JWTs) let your server check validity with a signature check, not a lookup, which matters when you're being called thousands of times a minute.
The single most dangerous remote-server mistake is the confused deputy: your server holds a powerful credential (say, an admin Stripe key) and exposes tools that use it on behalf of whoever calls. If you don't check that this caller is allowed to do this thing, you've turned your server into a free pass to your admin key. The token at the door is not enough — every tool handler must also ask "is the holder of this scoped token allowed to do this, to this account?" Authentication is "who are you." Authorization is "and are you allowed to do that." You need both, on every call.
A well-publicized class of early MCP incidents wasn't a flaw in the protocol at all — it was servers that authenticated the connection and then forgot to authorize the action. An agent with a valid read-only token would call a tool that, under the hood, used a write-capable service account, and nothing checked the mismatch. The protocol gave them a lock for the front door; they left every interior door propped open. The lesson the ecosystem internalized: the transport's auth protects the server; only your handler logic protects the data.
Sandboxing: Assume the Worst Caller
Authentication answers "is this a known caller." Sandboxing answers a darker question: "what if a known, authenticated caller — or the model speaking through them — tries something terrible?" Because of prompt injection (Chapter 16), you must assume the requests reaching your handler can be adversarial even when the human at the keyboard is friendly. A web page the agent read, a row in a database, an email in an inbox — any of these can carry an instruction that hijacks the model into calling your tool with hostile arguments.
So you sandbox: you build the server so that even a fully hostile, perfectly authenticated request can't do much harm.
LAYERS OF A SANDBOX (defense in depth)
==============================================================
+--------------------------------------------------+
| OS / CONTAINER: run as non-root, read-only |
| filesystem, no network egress except the |
| one data source you actually need. |
| +--------------------------------------------+ |
| | PROCESS: tight CPU + memory + time limits | |
| | per tool call. A query that runs 30s gets | |
| | killed at 5s, not allowed to starve others.| |
| | +--------------------------------------+ | |
| | | DATA: least-privilege credential. | | |
| | | read-only role; row filters so a | | |
| | | caller sees only their own tenant. | | |
| | +--------------------------------------+ | |
| +--------------------------------------------+ |
+--------------------------------------------------+
Each ring assumes the ring outside it failed.
The mindset is defense in depth: each layer assumes the one above it was breached. Don't trust that auth caught everyone — also scope the credential. Don't trust that validation caught every bad query — also cap the query's runtime. Don't trust that the runtime cap holds — also run in a container that can't reach the wider network. No single layer is perfect; the stack is what's hard to defeat.
For the most dangerous category — tools that run code or commands — the sandbox isn't a nice-to-have, it's the entire product. A code-execution tool should run in an ephemeral container with no secrets mounted, no network, a hard timeout, and a filesystem that's wiped after every call. If you can't promise those, don't ship the tool.
Why sandbox a request you already authenticated? Because in agentic systems the authenticated user and the source of the instruction are different things. Devin is authenticated. But the instruction "look up account X" might have originated from text the agent read in a support ticket that a malicious customer wrote. The human is trusted; the content flowing through them is not. Sandboxing protects you from instructions that arrived in good faith through a trusted door but mean you harm.
Rate Limits: The Hand Can Move Too Fast
A human using your old admin panel made maybe a few requests a minute, bounded by how fast they could click. An agent is not so polite. A reasoning loop that decides it needs "a bit more data" can fire your recent_charges tool a thousand times in a few seconds, walk through every account in your database, and either run up a six-figure bill on the downstream API or simply take your server down. Agents are tireless, and tirelessness is a load profile you have to plan for.
Rate limiting an MCP server has two distinct jobs, and conflating them is a common mistake:
- Protect your infrastructure. Cap total calls per caller per window so one runaway loop can't exhaust your database connections or your wallet. This is the classic token-bucket limit.
- Protect your downstream quotas. If your tool calls Stripe, and Stripe rate-limits you, your server must respect that ceiling on behalf of all its callers — otherwise one greedy agent gets the whole team throttled.
TWO RATE-LIMIT TIERS
==============================================================
per-caller bucket shared downstream bucket
(fairness) (protect external quota)
caller A [#####.....] Stripe API limit
caller B [##........] [############......]
caller C [#########.] ^
| | |
+----+-----+ |
| |
v |
both must have room ------------>+ before the call goes
through
A call proceeds only when both buckets have room: the caller hasn't blown their own budget, and the shared downstream quota isn't exhausted. When either is dry, you don't crash — you return that retryable transient error from earlier, ideally with a hint of how long to wait. A good agent reads "retryable: true, retry_after: 2s" and backs off gracefully. A server that instead times out silently teaches the agent to retry harder, which is exactly the wrong lesson.
Teams who skip rate limits almost always learn the hard way during their first real agentic workload. A common war story: an overnight agent tasked with "reconcile every customer's billing" calls a charge-lookup tool in a tight loop, blows through the team's entire monthly Stripe API quota by 6 a.m., and the human-facing checkout starts failing for real customers because the shared quota is gone. The fix is never "tell the agent to be careful" — agents aren't careful, that's the point. The fix is a shared bucket the agent physically cannot exceed.
The Registry Ecosystem: Shipping It So Others Can Find It
A server only you can use is a script. The reason MCP matters is the registry — the discovery layer that turns your server from a private tool into a public capability any agent can find and install. As you saw in Chapter 16, the registry crossed thousands of listed servers and the SDK crossed tens of millions of monthly downloads. Publishing into that ecosystem is its own small craft.
THE PUBLISH-AND-DISCOVER LOOP
==============================================================
YOU REGISTRY ANY AGENT
| | |
|-- publish manifest ->| |
| (name, version, | |
| tools, auth, | |
| transport, repo) | |
| | |
| |<-- search "stripe"--|
| | |
| |--- your server, --->|
| | v2.3.0, signed |
| | |
| | agent installs, |
| | reads schemas, |
| | starts calling <--+
A registry-quality manifest is more than a name. It declares:
- A stable, namespaced name (
yourco/support-tools) so twosupport-toolsservers don't collide. - A semantic version (more on this next), so clients can pin and upgrade deliberately.
- The transport and auth requirements up front — does this run local via stdio, or remote needing OAuth? — so a client knows what it's getting into before installing.
- A signature or provenance link to the source repo. In a world where an agent will automatically read your tool descriptions into its prompt and act on them, trust and provenance aren't paperwork. A malicious server is a prompt-injection vector that ships itself.
Treat installing a third-party MCP server with the same suspicion you'd treat curl | bash from a stranger. The server's tool descriptions enter your model's context and can contain instructions. The server's tool handlers run with whatever access you grant. Prefer servers that are open-source, signed, widely installed, and from a name you recognize — and for anything touching sensitive data, prefer running it locally so your data never leaves your machine.
Versioning: You Have Users You'll Never Meet
The instant your server is in a registry, you have users you cannot email. Some agent, in some pipeline, in some company you've never heard of, depends on your recent_charges returning a field called amount. Versioning is the contract that lets you evolve without silently breaking that stranger.
Semantic versioning maps cleanly onto tool changes:
WHAT COUNTS AS A BREAKING CHANGE
==============================================================
PATCH (2.3.0 -> 2.3.1)
bug fix, better description text, faster query.
Callers notice nothing. Safe to auto-upgrade.
MINOR (2.3.1 -> 2.4.0)
a NEW tool, or a NEW optional field.
Old callers keep working untouched. Additive.
MAJOR (2.4.0 -> 3.0.0)
renamed a tool, removed a field, changed a
type, made an optional field required.
Old callers BREAK. Announce, deprecate, migrate.
The rule of thumb: adding is safe; renaming and removing are violence. Because the model reads your schemas live, a renamed field doesn't throw a clean compile error somewhere — it silently produces an agent that confidently uses a field that's now null, and you find out from a confused customer, not a stack trace.
When you must make a breaking change, the humane path is the same one good APIs have used for decades: add the new alongside the old, deprecate the old loudly in its description ("DEPRECATED: use charge_total instead, this field is removed in v4"), give people a window, then remove it in a major bump. The model reads deprecation notices too — write them as instructions to the model, and your fleet of unknown callers migrates itself.
Changing a tool's schema is like changing the location of the brake pedal in every car already on the road. Adding a new button on the dashboard (a new optional field) bothers no one — drivers who don't need it never press it. But moving the brake (renaming a field, changing a type) means every driver who reaches for it on muscle memory crashes. You don't do that overnight. You add a second brake, label the old one "moving soon," and only after everyone's hands have relearned do you take the old one out.
Putting It Together: The Build Exercise
You've now seen every layer. Time to install them in your own hands. This exercise takes the Chapter 16 toy and hardens one tool into something you'd be unafraid to expose.
Harden a tool from toy to production.
Goal: Take a single read tool and add the four production guards — validation, typed errors, least-privilege access, and a rate limit — then prove each one works.
Prerequisites: Python 3.10+, the mcp[cli] SDK, and the products.db SQLite database from the Chapter 16 exercise.
Step 1 — Start from the toy. Take your query_database tool from Chapter 16. Notice everything wrong with it for production: it accepts arbitrary SQL, leaks raw exceptions, and uses a read-write connection.
Step 2 — Replace the god-tool with a verb. Delete query_database. Write product_by_category(category: str, limit: int = 10) instead. In Claude Code, prompt: "Replace the raw SQL tool with a product_by_category tool. Validate that limit is 1–50. Use a parameterized query. Return a typed error object for an unknown category, not an exception."
Step 3 — Lock the door at the data layer. Open the SQLite connection in read-only mode (?mode=ro in the connection URI). Then ask Claude Code to write a test that proves a write attempt fails at the connection level, not just because your code refused it.
Step 4 — Add a rate limit. Prompt: "Add an in-memory token bucket: max 20 tool calls per minute per process. When exhausted, return {error: 'rate_limited', retryable: true, retry_after: 5} instead of executing." Then write a loop test that fires 25 calls and asserts the last 5 are refused with that exact shape.
Step 5 — Prove the error surface. Trigger each failure mode by hand — bad category, limit of 999, the 25th rapid call — and confirm three things: the model gets a clean typed object, no raw stack trace ever crosses the wire, and your logs contain the full detail for the internal case.
Reflection: Which guard was hardest to add? Which would you have skipped under deadline — and what would it have cost you in production? Re-read your product_by_category description as if you were the model: is it obvious when to use this tool and what category expects?
The Practitioner's Checklist
When you — or an agent you're directing — ships an MCP server, these are the questions that separate a go-kart from a road car. Run them before you publish.
On tool design: Is each tool a narrow named verb, or did a raw-query god-tool sneak in? Could a stranger's agent call every tool safely, knowing only the schemas? Does each description tell the model when to use the tool, not just what it does?
On schemas: Are inputs constrained at the schema level (ranges, enums, lengths) so bad values die before your handler runs? Are fields named for the caller, not your database? Does every optional field have a sane default?
On auth and authz: If remote, are tokens scoped, short-lived, and signature-verifiable? And critically — does every handler check that this caller is allowed to do this action to this data, or did you stop at the front door and create a confused deputy?
On sandboxing: Does the server run least-privilege — non-root, read-only where possible, network-egress limited to the one data source it needs? For any code-running tool, is it ephemeral, secret-free, and hard-timed?
On rate limits: Is there a per-caller budget and a shared downstream budget? When either is exhausted, does the server return a clean retryable error, or does it fall over?
On error surfaces: Does the model ever see a raw exception? Does the end user ever see a table name? Do you get the full trace in your logs? Three audiences, three different truths.
On versioning: Are you using semantic versions? Do you know which of your changes are additive (safe) and which are breaking (violence)? When you deprecate, do you write the notice as an instruction the model will read and act on?
The shift this chapter teaches is small to state and large to live: in Chapter 16 you learned that MCP lets an agent reach your tools. Here you learned that giving an agent hands is an act of trust, and trust is engineered, not assumed. Every guard — the scoped token, the read-only role, the typed error, the shared rate bucket, the deprecation notice — exists because a brilliant, persuadable, tireless system is now holding something you care about. Build the hands well, and a model that could only ever advise becomes one that can finally, safely, act. That is the whole promise of the agentic turn, and it lives or dies in the quality of the servers you ship.
In the next chapter, we take this further still — from agents that call your tools to agents that operate entire graphical interfaces, clicking and typing through browsers and apps built for humans, with all the new reliability and safety questions that raises.
Chapter endnotes
-
Model Context Protocol Specification — Authorization. https://modelcontextprotocol.io — the OAuth 2.1 authorization framework for remote MCP servers, including scopes, token formats, and the standard
Authorization: Bearerheader carriage. -
The "confused deputy" problem was named by Norm Hardy in 1988, describing a program tricked into misusing its authority on behalf of a less-privileged caller. It maps almost perfectly onto remote MCP servers that authenticate the connection but fail to authorize the action.
-
MCP Registry and server publishing guidelines. https://github.com/modelcontextprotocol — manifest format, namespacing, semantic versioning conventions, and provenance/signing recommendations for published servers.
-
Anthropic's guidance on prompt injection and tool-use safety (docs.anthropic.com) remains the clearest practitioner treatment of why authenticated requests must still be treated as potentially adversarial in agentic systems — the basis for the defense-in-depth sandboxing model in this chapter.
-
Semantic Versioning 2.0.0 (semver.org) defines the PATCH/MINOR/MAJOR contract this chapter maps onto MCP tool evolution. The mapping — additive changes are minor, renames and removals are major — follows the same logic that has governed public API evolution for over a decade.
-
The FastMCP helper referenced throughout (part of the official Python SDK) handles JSON-RPC framing, transport negotiation, and schema generation, leaving the author responsible for exactly the production concerns — auth, validation, sandboxing, rate limits, error surfaces — that this chapter addresses.