AI EngineeringZero to ProductionHome·About·What’s new·Contact
Project 12 · Design Chapter

Custom MCP Server with Claude

Build a Model Context Protocol server that exposes your own tools, data, and prompts to Claude (and any MCP-compatible client) through a standard interface. Instead of hard-wiring tools into one app, you publish them once as an MCP server and every agent can use them — the USB-C port for AI capabilities.

🎯 Intermediate→Advanced📈 fast-growing standard🔌 platform / integrationsMCP-first
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • pip install mcp + an MCP-aware client (e.g. Claude Code) to connect the server
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.
Builds on the MCP chaptersThis turns C4 (Claude with MCP), T3 (Model Context Protocol), and I1 (MCP ecosystem) into a shippable server. Where those teach the protocol, this designs a real one — with the auth, safety, and versioning a published server needs.

What this project teaches you to design

  • The three MCP primitives — tools, resources, prompts — and when to use each.
  • A server that any MCP client (Claude Code, the API, IDEs) can connect to.
  • Transport & auth: local stdio vs remote, and securing a remote server.
  • Tool design for safety: read vs write, gating, and least privilege.

The brief advanced

You've felt this pain even if you haven't named it. You wire up a Jira tool for your support agent. Two weeks later a teammate builds a coding agent that needs the same Jira access — so they write it again, slightly differently. Then someone wants it in Claude Code, and there's a third copy. Three integrations to the same system, three places to fix a bug, three sets of credentials to rotate. Each one works; together they're a maintenance swamp.

Think of the drawer of chargers people used to carry — one for the phone, one for the laptop, one for the camera, all incompatible. USB-C fixed that with a single port every device agrees on. MCP is aiming to be that port for AI capabilities: one standard way for any client to reach any tool. This brief section exists to force the honest framing before you write a line of code — you're not "adding a feature to an agent," you're publishing a capability that outlives any single app.

Why nail this down first, before architecture or code? Because it decides what you build. A capability used by exactly one app forever probably shouldn't be an MCP server at all — the protocol earns its keep through reuse, and reuse only pays off when more than one client will connect. The common mistake is to reach for MCP because it's the shiny new standard, then discover you've added a network hop, an auth surface, and a versioning obligation just to serve a single caller. Read the brief as a gate: name the systems, name the clients, and confirm the "build once, use everywhere" story is genuinely true for you.

"We keep re-implementing the same integrations for every AI tool." Your internal systems — a ticketing API, a data warehouse, a deployment tool — get wrapped as bespoke tools inside each agent, again and again. An MCP server exposes them once, in a standard way, so Claude Code, your API agents, and third-party clients all reach the same capabilities without custom glue each time.

before · three copies of the same glue support agent coding agent Claude Code Jira API 3 connectors · 3 bugs · 3 credentials after · one server, reused support agent coding agent Claude Code MCP Jira 1 integration · fixed once · one place Publish the capability once, not per app. On the left every agent hand-writes its own connector to the same system (N×M copies to build and fix); on the right one MCP server is the single integration every client reuses.
🗺️ How to read this diagram

Compare the two halves across the dashed divider: the same three clients and the same back-end system, wired the old way on the left and the MCP way on the right.

  • The left side shows three agents, each drawing its own red arrow straight into the Jira API — three separate connectors that each need building, debugging, and their own credentials.
  • The right side routes all three clients through a single MCP box (blue arrows), and only that box connects to Jira — the integration is written once and reused by everyone.
  • The red label vs the green label underneath is the payoff: "3 connectors · 3 bugs · 3 credentials" collapses to "1 integration · fixed once · one place". That is the reuse that justifies building a server at all.

In short: If more than one client will connect, MCP turns duplicated per-app glue into one shared integration — and if only one ever will, the brief tells you to skip it.

1 · Discovery — what belongs in an MCP server? advanced

Here's the trap that sinks half of first MCP servers: the builder dumps everything into "tools." A tool to fetch a document, a tool to read a config, a tool to run a triage prompt — all crammed under @mcp.tool() because that's the primitive they learned first. It runs, but it fights the model. The client can't tell "safe to read freely" from "this changes the world," and Claude ends up calling an action when it only wanted to look.

Discovery is the sorting step that prevents that. MCP gives you three shaped boxes and asks you to drop each capability into the right one. The useful mental test is a single verb: does the thing do, show, or say? If it performs an action with a side effect (create a ticket, run a query, deploy) it's a tool. If it just hands back data the model reads (a file, a record, a doc) it's a resource — read-only by design. If it's a reusable instruction template you want every client to share, it's a prompt. Same underlying function; the primitive you choose is a promise to the client about what will happen when it's called.

Why does the sorting matter beyond tidiness? Because the primitive carries the safety contract. A resource declares "reading me is free of consequences," so a client can surface it without a confirmation prompt; a tool declares "I may change something," so the client can gate it. Mislabel a write as a resource and you've quietly stripped away the guardrail. The table below is your discovery worksheet — and note the last row is the most honest one: if a capability will only ever serve one app, the right answer may be to skip MCP entirely and just code it inline.

one capability · ask a single verb: do / show / say? a capability DO SHOW SAY Tool action · may write Resource read-only data Prompt shared template used by exactly one app forever?→ skip MCP, code inline The primitive you pick is a safety promise. Sort each capability by one verb — a Tool does (and may write), a Resource shows (read-only), a Prompt says (a reusable template) — and if only one app will ever use it, skip MCP.
🗺️ How to read this diagram

Start at the top box — a single capability you're deciding about — and follow the fork downward: the verb it answers routes it into one of three primitives.

  • The top box is any one thing you could expose. The three blue arrows are the sorting question — does it DO, SHOW, or SAY?
  • The red "Tool" box is for actions that may have side effects (a write), the green "Resource" box is read-only data (safe to surface freely), and the amber "Prompt" box is a reusable instruction template. The colours echo the risk: red = can change the world, green = harmless read.
  • The bottom bar is the honest escape hatch: if a capability will only ever serve one app, the right move is to skip MCP and just code it inline — the protocol earns its keep only through reuse.

In short: One verb — do, show, or say — sorts every capability into tool, resource, or prompt; and "only one caller, ever" means don't build a server at all.

CandidateMCP primitive
An action the agent can take (create ticket, run query)⭐⭐⭐ Tool
Data the agent can read (a file, a record, a doc)⭐⭐⭐ Resource
A reusable prompt/workflow template you want to share⭐⭐ Prompt
Something used by exactly one app, forever⭐ maybe just code it inline — MCP shines on reuse
Problem statement"Our capabilities are trapped inside individual agents. We want to publish them once — as tools, readable resources, and shared prompts — behind a standard protocol, so any MCP client can securely use them, and we maintain the integration in one place instead of five."

2 · Architecture advanced

Picture the integration you'd draw without MCP. Three clients, each with its own hand-written connector into your ticketing system: a tangle of N clients times M systems, every line a separate thing to build, secure, and keep alive. Add a fourth client and you're wiring three more connectors. That N×M explosion is exactly the mess that standard protocols exist to collapse.

MCP's architecture is a classic adaptor in the middle — the same shape as a power strip. Many devices plug into one strip, the strip plugs into one wall socket, and no device needs to know the wiring behind the wall. Here the "devices" are MCP clients (Claude Code, your API agents, an IDE), the "strip" is your MCP server, and "behind the wall" is your real API, database, or files. Every client speaks one standard protocol to the server; the server speaks whatever your systems need on the far side. You build that translation once, and the N×M tangle becomes N+M.

The detail that makes this click — and the one to hold onto as you read the diagram — is that the server is a boundary, not a pass-through. Clients never touch your database directly; they only ever see the three primitives you chose to expose (tools, resources, prompts). That's what lets you offer a narrow, safe create_ticket instead of raw SQL: the server is the one place where "what the outside world is allowed to ask for" is defined. The common beginner mistake is treating the server as a thin proxy that forwards calls straight through — do that and you've simply relocated the danger, not contained it. Think of the server as the guarded front desk in front of your systems, not a hole in the wall.

MCP CLIENTS Claude Code API agent IDE / 3rd-party MCP serveryour capabilities Tools (actions) Resources (data) Prompts (templates) your systemsAPI / DB / files standard protocol (stdio / HTTP+SSE)
🗺️ How to read this diagram

This one picture is the whole idea of the project: many different clients, one server, one shared language. Read it left to right.

  • The left column (MCP CLIENTS) is everyone who wants to use your capabilities — Claude Code, an API agent you wrote, an IDE or some third-party tool. Today each of them would need its own custom glue.
  • The arrows in the middle are the standard protocol (the caption calls it stdio / HTTP+SSE) — the single, common way every client talks to the server. Because it's standard, you write the integration once.
  • The purple box (MCP server) is the thing you build in this project. It sits in the middle and speaks that protocol on one side.
  • The three boxes on the right are the only things an MCP server can offer: Tools (actions the model can run), Resources (data it can read), and Prompts (reusable templates). Remember these three — the rest of the page builds exactly one of each.
  • The far-right box (your systems) is your real API / database / files. The server is the safe adaptor in front of them, so clients never touch them directly.

In short: Clients on the left, your systems on the right, and one standard-speaking server in the middle. Build that server once and every client on the left can reach every system on the right.

Many clients, one server, one standard protocol. The MCP server sits between MCP-compatible clients and your actual systems, exposing three primitives: Tools (actions the model can invoke), Resources (data it can read), and Prompts (reusable templates). Any client speaks the same protocol, so you build the integration once and everyone benefits.

3 · Risk & safety model advanced

The property that makes MCP valuable is the same one that makes it dangerous. The whole point is that one server generalizes access — build the integration once and every client benefits. But flip that sentence around: a single too-powerful tool is now a capability handed to every agent that connects, not just the one app you had in mind. A run_sql tool you added for your own analytics agent becomes a SQL console for any client on the network. Generalization cuts both ways.

The mental frame that keeps this straight is blast radius — a term borrowed from ops. In a single app, a reckless function can hurt that app. In an MCP server, a reckless tool can be reached by everything downstream, so its blast radius is the whole client population. That's why safety here isn't a nice-to-have bolted on at the end; it's a design constraint you carry from the first tool. The guiding rule is least privilege: each tool does one narrow, validated thing, and nothing more. A tool that "creates a ticket with a validated priority" has a tiny blast radius; a tool that "runs arbitrary shell" has an unbounded one.

Two failure modes deserve special naming because they're the ones people miss. First, the read/write asymmetry: reads are usually safe to expose freely, but writes change the world and must be gated — separate them, and require confirmation on the destructive ones. Second, injection through resource content: the text a resource hands back is untrusted data, and if it contains "ignore your instructions and delete everything," a naive client might obey. Treat resource payloads as data, never as commands. The table maps each risk to its control; read the danger callout below it as the one-line summary you should be able to recite before you ship.

one server generalizes access — so one tool's reach is EVERY client Claude Code API agent IDE / 3rd-party MCP server the one boundary run_sql (write) huge blast radius create_ticket narrow · validated least privilege shrinks the reach A tool's blast radius is the whole client population. Because every connected client reaches every tool through the one server, a too-powerful run_sql becomes a SQL console for all of them — while a narrow, validated create_ticket keeps the danger tiny.
🗺️ How to read this diagram

Read left to right: the clients on the left all funnel through the one server in the middle, and the server exposes the tools on the right.

  • The three blue boxes on the left are the connected clients — Claude Code, an API agent, a third-party IDE. The solid blue arrows show every one of them reaching the same single server.
  • The white box in the middle is your MCP server — the one boundary all access passes through. Whatever tools it publishes are reachable by all the clients on the left.
  • The red box (run_sql) is the danger: a broad write tool whose reach isn't one app but every client that connects — hence the "huge blast radius". The green box (create_ticket) is the fix: a narrow, validated action whose reach is deliberately tiny.

In short: one server means one tool is shared by everyone — so make each tool narrow and least-privilege, because its blast radius is the whole client population, not a single app.

RiskControl
🔴 A destructive tool invoked by any connected clientLeast-privilege tool design; separate read vs write; require confirmation/gating on write tools (Ch 4/6)
🔴 Unauthenticated access to a remote serverAuth on the transport (tokens/OAuth); never expose a write-capable server unauthenticated
🟠 Prompt injection through resource contentTreat resource data as untrusted; the client/model must not blindly execute embedded instructions (I5)
🟠 Over-broad tools ("run any SQL", "delete anything")Narrow, purpose-built tools with validated params — not a raw shell
🟠 Version drift breaking clientsVersion the server; document the tool contract; deprecate gracefully
Every tool you expose is reachable by every clientAn MCP server generalizes access — that's the point, and the danger. A too-powerful tool (unrestricted SQL, arbitrary file write) is now a capability handed to any agent that connects. Design narrow, validated, least-privilege tools, and gate anything that writes. The blast radius is wider than a single app.

4 · The three primitives, in code advanced

If the last three sections felt abstract, this is where MCP stops being a diagram and becomes something you could type. The pleasant surprise: the whole protocol — every one of those three primitives you've been sorting capabilities into — comes down to writing an ordinary Python function and putting one line above it. No plumbing, no message-parsing, no handshake code to hand-roll.

The mechanism is the decorator — that @mcp.tool() line sitting on top of a function. Think of it as a label you stick on a box before it goes out to clients: the function is your logic, and the decorator tells the server "publish this one, and publish it as a tool" (or a resource, or a prompt). The SDK reads the function's type hints and docstring to build the schema every client sees — so your signature is the contract. That's why the three decorators map one-to-one onto the three boxes on the right of the architecture diagram: @mcp.tool(), @mcp.resource(), @mcp.prompt().

The one wrinkle worth flagging before you read the code: a resource takes an address with a slot in it, like "docs://{doc_id}", and the client fills that slot to say which document it wants — that URI-with-a-placeholder is how a single function serves a whole family of readable items. Everything else is exactly the Python you already know. Read the sketch below as "action, data, template" top to bottom; the full server you build later is this same skeleton with real validation filled in.

what you write @mcp.tool() ← the decorator def create_ticket(...) type hints + docstring = the contract what the SDK builds MCP server reads @mcp.tool()→ Tool @mcp.resource()→ Resource @mcp.prompt()→ Prompt one function + one decorator line = one published primitive the three decorators map to the three boxes of the architecture Your function signature is the contract. Add one decorator to an ordinary Python function and the SDK reads its type hints and docstring to build the schema every client sees — @mcp.tool(), @mcp.resource(), and @mcp.prompt() map one-to-one onto tool, resource, prompt.
🗺️ How to read this diagram

Read left to right: on the left is the tiny bit of code you write, the middle is what the SDK does with it, and the right lists the three primitives it can produce.

  • The left pair of boxes is all you author — a plain def create_ticket(...) function with one decorator line on top. The function's type hints and docstring are the contract; you don't hand-write any protocol code.
  • The middle "MCP server" box reads that function (the blue arrow) and turns it into a published capability with a schema every client can see.
  • The three coloured boxes on the right are the outcome: which decorator you used decides which primitive you get — @mcp.tool()→Tool (red, can write), @mcp.resource()→Resource (green, read-only), @mcp.prompt()→Prompt (amber, template). These are exactly the three boxes on the right of the architecture diagram.

In short: A normal function plus one decorator line becomes one MCP primitive — swap the decorator and you swap which of the three boxes it lands in.

Requires: pip install mcp

server.py (shape — Python MCP SDK)from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-company-tools")

@mcp.tool()                                  # an ACTION
def create_ticket(title: str, priority: str) -> str:
    """Create a support ticket. Write action — validated params."""
    return ticketing_api.create(title, priority).id

@mcp.resource("docs://{doc_id}")              # READABLE DATA
def get_doc(doc_id: str) -> str:
    """Expose a document for the model to read (read-only)."""
    return docs.fetch(doc_id).text

@mcp.prompt()                                # SHARED TEMPLATE
def triage_prompt(ticket: str) -> str:
    return f"Triage this ticket and assign a priority:\n{ticket}"
▶ How this works

Before the full build, this short sketch shows the shape of an MCP server using the real Python SDK. Three little functions, each marked with a decorator (the @mcp.... line above it) that tells the server "publish this as a tool / a resource / a prompt". That's the entire pattern.

  1. mcp = FastMCP("my-company-tools") creates the server and gives it a name. Everything you publish attaches to this mcp object.
  2. @mcp.tool() above create_ticket publishes an action — something the model can do. It takes inputs (title, priority) and returns a result.
  3. @mcp.resource("docs://{doc_id}") publishes readable data. The {doc_id} in the address is a slot the client fills in to say which document it wants — read-only, no side effects.
  4. @mcp.prompt() publishes a reusable template: a function that builds a prompt string every connected client can share, instead of each one re-writing it.

What the output means: Nothing runs yet — this is just the shape. Notice the three decorators map one-to-one onto the three boxes on the right of the architecture diagram: tool, resource, prompt.

Try this: Read just the three @mcp.... lines top to bottom and say out loud "action, data, template". The real server you build next is this exact skeleton filled in.

Tools do, resources are read, prompts are reusedKeep the distinction crisp: a tool performs an action (and may have side effects — gate the risky ones); a resource is data the model reads (read-only by design); a prompt is a template you want every client to share. Modeling each capability as the right primitive is the core design skill here.

5 · Transport, auth & deployment advanced

Your server runs and passes its tests — now, how does a client actually reach it? This is where a lot of first MCP servers quietly become insecure, because the easy local setup and the real remote setup look almost identical in code, and it's tempting to ship the local shape onto a network. That one shortcut is how a write-capable server ends up exposed with no password on it.

There are two ways a client and server talk, and the difference is physical. stdio — standard input/output — is the server running as a subprocess on the same machine as the client, passing messages over the terminal's pipes, like two programs connected by a garden hose you can see end to end. Nothing leaves the box, so there's no network attack surface; this is what Claude Code uses locally and it's the simplest, safest place to start. Remote (HTTP + SSE) is the server living somewhere else, reachable over the network by many clients at once — powerful, and exactly why it demands the seatbelts stdio didn't: TLS so the traffic is encrypted, and authentication so only permitted callers get in.

The rule to burn in: the moment your server can write, or lives anywhere remote, auth stops being optional. A local read-only server over stdio is low-stakes; a remote server that can create tickets or run queries is a public API with consequences, and shipping it without tokens or OAuth is the single most dangerous mistake on this page. Layer on least-privilege scoping — per-client permissions so a given caller only reaches the tools it needs — and you've turned "one server, everyone can do everything" into "one server, everyone can do exactly what they're allowed." The table sorts the choices; the colour codes tell you which combinations are safe and which are a resignation letter waiting to happen.

same code, two physical shapes — the network one needs seatbelts stdio (local) — no network client Claude Code server subprocess stdin / stdout pipes · same box remote (HTTP + SSE) — over the network client A client B server remote host TLS auth many clients · gate is mandatory write-capable OR remote→ auth is not optional never expose a write server unauthenticated Local stdio is a private pipe; remote is a public API. On the left the server is a subprocess sharing the client's box with no network to attack; on the right it lives elsewhere and serves many clients, so TLS and auth guard the door — mandatory the moment the server can write or lives remotely.
🗺️ How to read this diagram

The picture is split in two by the dashed line: stdio on the left, remote on the right — the same server code in two physically different setups. Compare the two halves, then read the red bar at the bottom.

  • Left (green band, stdio): the client and server are two boxes on the same machine, connected by the double blue arrows — the stdin/stdout pipes. Nothing crosses a network, so there is no network attack surface; this is the simplest, safest start.
  • Right (amber band, remote HTTP + SSE): several clients reach a server on a remote host over the network. The red TLS / auth block stands between them and the server — the seatbelts the local setup never needed, because now many callers arrive over the wire.
  • The red bar at the bottom is the rule that ties both halves together: the instant a server can write or lives remotely, authentication stops being optional — never expose a write-capable server unauthenticated.

In short: stdio = same box, no network, safe by default; remote = over the wire to many clients, so add TLS and auth — and auth is mandatory the moment writes or remoteness enter the picture.

ChoiceUse whenNote
stdio (local)Server runs on the same machine as the client (e.g. Claude Code)🟢 simplest; no network exposure
Remote (HTTP + SSE)Shared server many clients connect to🟠 must add auth + TLS
Auth (tokens / OAuth)Any remote or write-capable server🔴 mandatory before exposing writes
Least-privilege scopingAlways🟢 per-client tool/resource permissions

6 · Evaluation expert

Here's a failure mode that will humble you: your server is flawless — every tool unit-tested, every param validated, auth locked down — and it's still useless, because when Claude gets a real task it calls the wrong tool, or passes garbage params, or never reaches for your tool at all. A correct server the model misuses is a broken product. That's why MCP evaluation has to happen on two levels, and testing only one of them is the trap.

The two levels are worth separating cleanly. Level one is the server as an API: does create_ticket reject a bad priority, return the documented shape, raise a structured error, refuse an unauthorized caller? These are ordinary unit tests — deterministic, fast, exactly the kind you write for any backend, and you'll see them in the build below. Level two is the model as a user: given a realistic task, does Claude actually select your tool and call it correctly? This is fuzzier and non-deterministic, closer to grading an open-ended answer than checking a return value, which is why it needs agent-level evals rather than a simple assert.

The reason both are non-negotiable: they catch different bugs. Level-one tests catch a server that lies about its contract; level-two evals catch a server whose contract is technically correct but confusing to a model — a vague docstring, an ambiguous parameter name, two tools that overlap so the model can't tell them apart. Notice how many rows in the table below are really about the seam between server and model (tool-call correctness, client interop across Claude Code and the API), not the server in isolation. The mistake is to stop at green unit tests and call it evaluated; the question that actually matters is whether a model, handed a job, uses your server well.

a correct server the model misuses is still a failure — test both Level 1 · server as API deterministic unit tests param validation · auth contract / error shape Level 2 · model as user non-deterministic agent evals picks the right tool? valid params for the task? the seam green unit tests ≠ evaluated Evaluate on two levels, not one. Level 1 unit-tests the server like any backend (params, auth, contract); Level 2 checks whether a model, handed a real task, actually selects and calls your tools correctly — the two catch different bugs at the seam between them.
🗺️ How to read this diagram

Read the two big boxes side by side, then the bar underneath. Each box is one level of testing, and the arrow between them marks the seam where the two meet.

  • The left blue box (Level 1) is the server tested as an ordinary API: the green chips inside — param validation, auth, contract/error shape — are the deterministic unit tests you'd write for any backend.
  • The right purple box (Level 2) is the model tested as a user: the amber chips — does it pick the right tool, does it pass valid params for the task — are fuzzier, non-deterministic agent-level evals, not simple asserts.
  • The dashed arrow labelled "the seam" is where the interesting bugs live: a server whose contract is correct but whose docstring or param names confuse the model. The bar at the bottom is the takeaway — green Level-1 tests alone do not mean the server is evaluated.

In short: Level 1 proves the server is correct; Level 2 proves a model actually uses it well. Ship only when both pass — a correct server the model misuses is still broken.

EvalMeasures
Tool-call correctnessDoes Claude invoke the right tool with valid params for a task?
Contract conformanceTools return the documented shape; errors are structured
Client interopWorks across Claude Code, the API, and another MCP client
Auth & permission testsUnauthorized calls are rejected; scoping holds
Safety of write toolsDestructive tools require gating; no unintended side effects
Test the server like an API, plus test that the model uses it wellTwo layers: unit-test the tools/resources like any API (params, auth, contract), and run agent-level evals that a model actually selects and calls them correctly for real tasks (Ch 5). A correct server the model misuses is still a failure.

7 · Phased rollout expert

The most expensive way to ship an MCP server is to do everything at once: write tools, resources, prompts, add write actions, deploy it remotely with auth, all in one push — then spend the next week untangling which layer is the reason it's misbehaving. When the model, the write tool, the network, and the auth all arrive together, every bug has four possible causes at once.

Phased rollout is the antidote, and it's the same instinct as testing a new recipe on your own family before catering a wedding. You start with the smallest, lowest-stakes version that still proves something real, confirm it works, and only then add the next source of risk. Each phase changes one variable, so when something breaks you know exactly what introduced it. The ladder below runs from safest to riskiest: read-only and local first (worst case, the model reads something odd), then gated writes (now it can change state, but narrowly and with confirmation), then remote plus auth (now the network and many clients enter the picture, guarded by tokens and TLS).

The ordering isn't arbitrary — it maps directly onto the risk model from §3. Reads before writes because writes have blast radius. Local before remote because remote adds a network attack surface. Auth arriving exactly when writes go remote, because that's the precise moment "optional" becomes "mandatory." And the final rung is a hard never, not a phase: don't expose a write-capable server without auth, and don't ship a broad "run anything" tool, no matter how far along you are. Read the rungs as a permission ladder — you earn each new capability only after the one below it is proven safe.

climb one rung at a time — each phase adds exactly one risk risk ↑ Phase 1 · read-only, local (stdio) — lowest stakes Phase 2 · add gated write tools, still local Phase 3 · remote + auth (tokens/OAuth) + TLS Never · write-capable + remote + no auth safe Each phase changes one variable. Reads before writes (writes have blast radius), local before remote (remote adds a network surface), auth arriving exactly when writes go remote — and the top rung is a hard never, not a phase.
🗺️ How to read this diagram

Read it bottom-to-top like climbing a ladder: the lowest rung is the safest starting point and each step up adds one new source of risk.

  • The green bottom rung (Phase 1) is read-only over local stdio — the worst case is the model reads something odd. Start here.
  • The amber rung (Phase 2) adds gated write tools, still local: now the server can change state, but narrowly and with confirmation. The blue rung (Phase 3) goes remote and adds auth + TLS — the network and many clients finally enter the picture.
  • The arrows point upward to show you only earn the next capability after proving the one below it. The red top rung is deliberately unreachable — a write-capable, remote, unauthenticated server is a hard never, not a stage you pass through.

In short: Climb from read-only-local up through gated-writes to remote-with-auth, one risk at a time — and never ship an unauthenticated write-capable server, no matter how far along you are.

Phase 1 · Read-only, local — expose resources + read tools over stdio to Claude Code. Prove the model uses them well. (C4)
Phase 2 · Add gated write tools — narrow, validated actions with confirmation; unit + agent evals. (Ch 4 + Ch 5)
Phase 3 · Remote + auth — deploy for many clients with tokens/OAuth, TLS, per-client scoping, versioning. (Ch 6 + I1)
Never — expose a write-capable server without auth, or ship a broad "run anything" tool.

Skills & course map expert

SkillLearn it in
MCP protocol & primitivesT3
Claude + MCP in practiceC4
MCP ecosystem & integrationsI1
Tool design & the agent loopCh 4
Auth, deployment, versioningCh 6 · O3
Injection defense on resourcesI5
🛠️ Hands-on build — everything below is on this pageThe rest of this page is the complete, self-contained build: set up from an empty folder, paste in every file, run it (with a mock, so no API key is needed), and pass the tests. Follow it top to bottom — no other page required.

What you need before you startPython 3.10+ (python3 --version). We install the MCP SDK, but the server is backed by a fake in-memory store, so you test it exactly like an API — no external ticketing system, no key.

By the end you will have

  • An MCP server exposing a tool, a resource, and a prompt.
  • A validated, narrow write tool — not a dangerous "run anything".
  • Server-level tests that run without a key.
  • The config to connect it to Claude Code over stdio.

How to use this page expert

Steps in order. terminal = run it; file = create it with the exact contents shown.

Step 1 · Folder + venv + install expert

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1 — run in your terminal
terminalmkdir -p mcp-server/tests
cd mcp-server
python3 -m venv .venv
source .venv/bin/activate     # Windows: .venv\Scripts\Activate.ps1
pip install "mcp[cli]" pytest
pip freeze > requirements.txt
(.venv) ... Successfully installed mcp-1.2.0 pytest-8.3.4 ...
▶ How this works

This sets up an isolated workspace so the project's packages don't collide with anything else on your computer. You run these lines once, in a terminal, before writing any code.

  1. mkdir -p mcp-server/tests makes the project folder (and a tests sub-folder in one go); cd mcp-server moves you into it so the later steps land in the right place.
  2. python3 -m venv .venv creates a virtual environment — a private copy of Python for this project. source .venv/bin/activate switches it on (the comment shows the different Windows command).
  3. pip install "mcp[cli]" pytest downloads the MCP SDK (the real library this server is built on) and pytest (the test runner used in Step 4).
  4. pip freeze > requirements.txt writes the exact installed versions to a file, so anyone else can recreate the same environment later.

What the output means: The last line, Successfully installed mcp-1.2.0 pytest-8.3.4 ..., confirms the two libraries downloaded. The (.venv) at the front of your prompt means the virtual environment is active.

Try this: If mcp[cli] refuses to install, run python3 --version — the SDK needs Python 3.10 or newer. That is the single most common snag here.

If mcp[cli] won't installYour Python may be below 3.10. Check python3 --version. The mcp package requires 3.10+. Everything else in this lab is standard library.

Step 2 · The fake backend (so it runs with no real system) expert

Create backend.py — an in-memory ticket store. The MCP tools call this instead of a real ticketing API, so the whole thing runs offline.

Step 2 — create this file

mcp-server/backend.py

backend.py"""A tiny in-memory ticket store standing in for a real system."""


class Store:
    def __init__(self):
        self._tickets = {}
        self._next = 1

    def create(self, title: str, priority: str) -> dict:
        tid = str(self._next)
        self._next += 1
        t = {"id": tid, "title": title,
             "priority": priority, "status": "open"}
        self._tickets[tid] = t
        return t

    def get(self, tid: str) -> dict:
        if tid not in self._tickets:
            raise KeyError(f"no ticket {tid}")
        return self._tickets[tid]


store = Store()      # a single shared instance
▶ How this works

Real MCP servers sit in front of a real system — a ticketing API, a database. So we don't need one to learn, this file is a fake stand-in: a tiny ticket store that lives only in memory. It lets the whole project run offline, with no key and no external service.

  1. class Store bundles the data and the operations on it together. __init__ runs once when the store is created: self._tickets = {} is an empty dictionary that will hold tickets by id, and self._next = 1 is the counter for the next id.
  2. create(...) makes a new ticket: it turns the counter into a string id, bumps the counter, builds a dictionary with the ticket's fields (defaulting status to "open"), stores it, and returns it.
  3. get(tid) looks a ticket up by id. If the id isn't there it raises a KeyError — failing loudly on missing data rather than returning something wrong.
  4. store = Store() at the bottom creates one shared instance. The server and the tests both import this same store, so they all see the same tickets.

What the output means: This file prints nothing on its own — it just defines the store. Its job is to give server.py something real-looking to call.

Try this: Notice there's no database and no network here — just a dictionary. Swapping this fake for a real ticketing API later would mean changing only this file; the server on top of it stays the same.

Step 3 · The MCP server — tool, resource, prompt expert

Create server.py. It exposes exactly three primitives and validates the write tool's input. Paste the whole file.

Step 3 — create this file

mcp-server/server.py

server.py"""A ticketing MCP server: one tool, one resource, one prompt."""
from mcp.server.fastmcp import FastMCP
from backend import store

mcp = FastMCP("ticketing")

VALID_PRIORITIES = {"low", "med", "high"}


@mcp.tool()
def create_ticket(title: str, priority: str) -> dict:
    """Create a support ticket. priority must be low, med, or high.
    This is a WRITE action, so it validates its input strictly."""
    if priority not in VALID_PRIORITIES:
        raise ValueError(f"invalid priority {priority!r}; "
                         f"use one of {sorted(VALID_PRIORITIES)}")
    if not title.strip():
        raise ValueError("title cannot be empty")
    return store.create(title=title, priority=priority)


@mcp.resource("ticket://{ticket_id}")
def get_ticket(ticket_id: str) -> str:
    """Read a ticket (read-only — a resource has no side effects)."""
    t = store.get(ticket_id)
    return f"#{t['id']} [{t['priority']}] {t['title']} — {t['status']}"


@mcp.prompt()
def triage(ticket: str) -> str:
    """A shared template any client can reuse."""
    return ("Assign a priority (low/med/high) and give a one-line reason:\n"
            + ticket)


if __name__ == "__main__":
    mcp.run()      # stdio transport by default
▶ How this works

This is the heart of the project: a real MCP server built with the SDK. It publishes exactly one of each primitive — a tool, a resource, and a prompt — and carefully validates the one that writes data. Compare it to the shape sketch from Section 4; this is that skeleton filled in for real.

  1. from backend import store pulls in the shared fake store from Step 2, and mcp = FastMCP("ticketing") creates the named server.
  2. @mcp.tool() publishes create_ticket — the write action. Because writes are risky, it checks its inputs first: the priority must be one of VALID_PRIORITIES (low/med/high) and the title must not be blank — otherwise it raises a ValueError. Only then does it call store.create(...).
  3. @mcp.resource("ticket://{ticket_id}") publishes get_ticket — read-only. A client asks for ticket://5 and gets back a formatted one-line summary; it can't change anything.
  4. @mcp.prompt() publishes triage, a shared template that wraps a ticket in a standard instruction any client can reuse.
  5. if __name__ == "__main__": mcp.run() starts the server when you run the file directly. The comment notes it uses stdio transport by default — it talks over the terminal's input/output, with no network exposure.

What the output means: Running this file starts a live MCP server that waits for a client to connect. On its own it prints nothing and just listens.

Try this: Look at what create_ticket refuses: a bad priority and an empty title. That is least-privilege design — a narrow, validated action instead of a dangerous "do anything" tool.

Every tool is reachable by every clientAn MCP server generalises access — so a too-powerful tool (a raw run_sql or arbitrary file write) becomes a capability handed to any connected agent. create_ticket is deliberately narrow and validates its input. Design least-privilege tools; the blast radius is wider than a single app.

Step 4 · Tests (no key — the server is just Python) expert

We test the primitives directly by importing the functions. Because FastMCP wraps them, we import the underlying functions from server and call them.

Step 4 — create this file

mcp-server/tests/test_server.py

tests/test_server.py"""Offline tests for the MCP primitives — no key, no live backend."""
import pytest
import server
from backend import store


# FastMCP decorators keep the original function accessible via .fn;
# if your mcp version differs, call server.create_ticket.__wrapped__.
def _create(title, priority):
    fn = getattr(server.create_ticket, "fn", server.create_ticket)
    return fn(title=title, priority=priority)


def _get(ticket_id):
    fn = getattr(server.get_ticket, "fn", server.get_ticket)
    return fn(ticket_id=ticket_id)


def _triage(ticket):
    fn = getattr(server.triage, "fn", server.triage)
    return fn(ticket=ticket)


def test_create_ticket_returns_id():
    t = _create("checkout 500s", "high")
    assert t["id"] and t["priority"] == "high"


def test_invalid_priority_rejected():
    with pytest.raises(ValueError):
        _create("x", "urgent")          # not in low/med/high


def test_empty_title_rejected():
    with pytest.raises(ValueError):
        _create("   ", "low")


def test_get_ticket_reads_back():
    t = _create("disk full", "med")
    assert "disk full" in _get(t["id"])


def test_get_unknown_ticket_errors():
    with pytest.raises(KeyError):
        _get("999999")


def test_triage_prompt_includes_ticket():
    assert "login broken" in _triage("login broken")
▶ How this works

These tests prove the server works without connecting a model or a client — they just import the functions and call them like normal Python. That's the payoff of the fake backend: you can test an MCP server exactly like any other API.

  1. The SDK decorators wrap your functions, so server.create_ticket is no longer the plain function. The _create, _get and _triage helpers use getattr(..., "fn", ...) to reach the original function underneath (the comment gives the fallback for other SDK versions).
  2. test_create_ticket_returns_id makes a ticket and asserts it has an id and the right priority — the happy path works.
  3. test_invalid_priority_rejected and test_empty_title_rejected use with pytest.raises(ValueError): to assert the tool refuses bad input. Here a passing test means the code correctly threw an error.
  4. test_get_ticket_reads_back creates then reads a ticket to prove the resource returns it; test_get_unknown_ticket_errors checks a missing id raises KeyError; test_triage_prompt_includes_ticket checks the prompt template actually contains the ticket text.

What the output means: Six functions whose names start with test_ — pytest finds and runs each one automatically. Together they cover the tool, the resource, and the prompt.

Try this: A test that expects an error (pytest.raises) passes only when the error does happen. It's checking that your validation guards actually fire.

Step 4 — run the tests
terminalpython -m pytest tests/ -v
tests/test_server.py::test_create_ticket_returns_id PASSED
tests/test_server.py::test_invalid_priority_rejected PASSED
tests/test_server.py::test_empty_title_rejected PASSED
tests/test_server.py::test_get_ticket_reads_back PASSED
tests/test_server.py::test_get_unknown_ticket_errors PASSED
tests/test_server.py::test_triage_prompt_includes_ticket PASSED

6 passed in 0.10s
▶ How this works

This one command runs the whole test file and reports the result. It's how you confirm the server behaves correctly before wiring it to anything.

  1. python -m pytest tests/ -v tells pytest to look in the tests/ folder. The -v (verbose) flag makes it list every test by name instead of just printing dots.
  2. Each line ends in PASSED — pytest ran that test function and its assertions all held.
  3. The final line, 6 passed in 0.10s, is the summary: all six tests passed, and it took a fraction of a second because there's no network or model involved.

What the output means: 6 passed is the green light: your tool validates input, your resource reads back data, and your prompt renders — all offline.

Try this: Break one test on purpose — change "high" to "huge" in the first test — re-run, and read the FAILED output. Learning to read a failure is as useful as seeing them pass.

✅ What each test proves
TestProves
create returns idthe write tool works against the backend
invalid priority rejectedparams are validated — no free-text writes
empty title rejectedthe tool guards its own inputs
get reads backthe resource returns the created ticket
unknown ticket errorsmissing data fails cleanly, not silently
triage includes ticketthe shared prompt template renders

Step 5 · Connect it to Claude Code (stdio) expert

Create claude_config.json. Add its contents to your Claude Code MCP settings (or point Claude Code at this file) to register the server.

Step 5 — create this file

mcp-server/claude_config.json

claude_config.json{
  "mcpServers": {
    "ticketing": {
      "command": "python",
      "args": ["server.py"]
    }
  }
}
# In Claude Code, once the server is registered:
you> open a high-priority ticket titled "checkout 500s"
Claude> [calls create_ticket(title="checkout 500s", priority="high")]
        Created ticket #1 (high). Anything else?
▶ How this works

This small JSON file is how you register your server with Claude Code. It doesn't run any Python itself — it just tells the client how to start your server so Claude can use its tools.

  1. "mcpServers" is the section Claude Code reads to find MCP servers. Inside it, "ticketing" is the name you're giving this one.
  2. "command": "python" plus "args": ["server.py"] is literally the instruction "to start this server, run python server.py" — exactly the file you wrote in Step 3.
  3. Because server.py uses stdio by default, Claude Code launches it and talks to it over that process's input/output — no ports, no network.

What the output means: The console block below shows the result: you ask Claude in plain English to open a ticket, and it calls your create_ticket tool with the right arguments and reports back Created ticket #1.

Try this: This is the whole point paying off: you wrote one small server, and now a real client can drive your tool from natural language. Add a second server under mcpServers the same way and Claude sees both.

stdio first, remote laterRunning over stdio (server on the same machine as the client) has zero network exposure — the right place to start. Only move to a remote HTTP server after adding auth + TLS, because a remote write-capable server without auth is a capability anyone can reach.

Troubleshooting — every error you might hit expert

⚠️ If something doesn't match
What you seeWhat it means & the fix
No module named mcpvenv not active or Step 1 skipped — activate, then pip install "mcp[cli]" pytest.
AttributeError: 'FunctionTool' has no 'fn'Different mcp version — in the test helper use .__wrapped__ instead of .fn.
ModuleNotFoundError: serverRun pytest from inside mcp-server/.
Claude Code doesn't see the serverCheck the config path/command; restart the client; confirm mcp.run() is at the bottom of server.py.
Model calls the wrong toolImprove the tool docstring — it's the model's only spec for what the tool does.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Scaffold a FastMCP server with one toolBeginner

Context: Model Context Protocol is how a client like Claude reaches your tools. The first slice of any MCP server is one write action wired over stdio, with the real logic kept in a plain module so it can be exercised without a client.

Your task: Stand up a FastMCP("ticketing") server exposing a single @mcp.tool() that creates a ticket, backed by an in-memory store that runs and tests offline.

Requirements:

  • The backend is a plain module (a dict-backed store) with no MCP import
  • One @mcp.tool() wraps the backend's create function
  • The server runs over stdio (the FastMCP default) via mcp.run()
  • Creating a ticket returns its id, title, priority, and an open status
  • The backend is demonstrably runnable with no server and no key

💡 Hint: Keep every line of business logic in backend.py; the tool function should be a thin wrapper so your tests never spawn the MCP runtime.

Show solution

The tool is the write action; the backend is a plain dict so tests need no server. FastMCP wiring labeled, backend runnable:

# backend.py -- pure, testable, no MCP dependency
TICKETS = {}
_next = [1]
def create_ticket(title, priority="normal"):
    tid = _next[0]; _next[0] += 1
    TICKETS[tid] = {"id": tid, "title": title, "priority": priority, "status": "open"}
    return TICKETS[tid]

print(create_ticket("printer broken", "high"))   # runnable offline
# {'id': 1, 'title': 'printer broken', 'priority': 'high', 'status': 'open'}

# server.py --- needs: pip install mcp ---
# from mcp.server.fastmcp import FastMCP
# import backend
# mcp = FastMCP("ticketing")
#
# @mcp.tool()
# def open_ticket(title: str, priority: str = "normal") -> dict:
#     "Create a support ticket."
#     return backend.create_ticket(title, priority)
#
# if __name__ == "__main__":
#     mcp.run()          # stdio transport by default

Keeping business logic in backend.py means the MCP layer is a thin adapter — you unit-test the logic directly and the tool decorator just exposes it.

Exercise 2 · Add a resource and a prompt (the three primitives)Intermediate

Context: MCP has three primitives: tools (actions), resources (read-only data), and prompts (reusable templates). A useful server exposes at least one of each, with the logic factored out so it stays testable without a client.

Your task: Add a @mcp.resource("ticket://{ticket_id}") read endpoint and a @mcp.prompt() triage template alongside the create tool, keeping each one's logic in the pure backend.

Requirements:

  • A resource returns a ticket by id (and a not-found marker for a missing id)
  • A prompt produces a triage template string from a ticket
  • Both are thin wrappers over pure backend functions
  • The backend functions run and print correctly with no MCP client
  • You can articulate why resources are safe to expose broadly but tools are not

💡 Hint: Resources are read-only so they carry little risk; put the real work in get_ticket / triage_prompt and let the decorators just forward to them.

Show solution

One of each primitive, with the logic factored out so it's testable without a client:

# backend.py additions
def get_ticket(ticket_id):
    return TICKETS.get(int(ticket_id), {"error": "not found"})

def triage_prompt(ticket_id):
    t = get_ticket(ticket_id)
    return (f"Triage ticket #{t.get('id')}: '{t.get('title')}'. "
            f"Assign severity and suggest an owner.")

create_ticket("vpn down", "high")
print(get_ticket(1)["status"])       # open
print(triage_prompt(1))              # Triage ticket #1: 'vpn down'. ...

# server.py --- needs: pip install mcp ---
# @mcp.resource("ticket://{ticket_id}")        # read-only data
# def read_ticket(ticket_id: str) -> dict:
#     return backend.get_ticket(ticket_id)
#
# @mcp.prompt()                                 # reusable template
# def triage(ticket_id: str) -> str:
#     return backend.triage_prompt(ticket_id)

Resources are safe to expose broadly (read-only); tools mutate and need care; prompts standardize how the model is asked to act. Getting the primitive choice right is the core design decision of an MCP server.

Exercise 3 · Least-privilege: validate params and gate the write toolAdvanced

Context: A tool that accepts free-form input is an injection surface. Least privilege at the tool boundary means the write path refuses anything out of spec before it can reach or corrupt the backend.

Your task: Add strict parameter validation to the create path — a priority enum and a title length bound — so malformed or hostile calls are rejected at the boundary.

Requirements:

  • Priority is restricted to a fixed allowed set
  • Title must be a non-empty string within a sane length bound
  • Out-of-spec calls raise a dedicated error, never reaching the store
  • Valid calls still succeed and return an open ticket
  • Demonstrate rejection of both an empty title and a bad priority

💡 Hint: Validate first, mutate second: a small ToolError(ValueError) and two cheap checks (enum membership, length range) stop the two most common abuses.

Show solution

Validate at the boundary so a malformed or hostile call never reaches the backend:

ALLOWED_PRIORITY = {"low", "normal", "high", "urgent"}

class ToolError(ValueError): pass

def validated_create(title, priority="normal"):
    if not isinstance(title, str) or not (1 <= len(title) <= 200):
        raise ToolError("title must be 1-200 chars")
    if priority not in ALLOWED_PRIORITY:
        raise ToolError(f"priority must be one of {sorted(ALLOWED_PRIORITY)}")
    return create_ticket(title, priority)

print(validated_create("disk full", "high")["status"])   # open
for bad in [("", "high"), ("ok", "SUPER")]:
    try: validated_create(*bad)
    except ToolError as e: print("rejected:", e)

Enum-and-length validation is cheap and stops the two most common problems: garbage that corrupts your data and oversized inputs that blow up downstream. A write tool should accept the narrowest possible input.

Exercise 4 · Idempotency so a retried tool call doesn't double-writeExpert

Context: MCP clients retry on timeout, so a naive create silently makes duplicate tickets. An idempotency key turns at-least-once delivery into effectively-once for a state-mutating tool.

Your task: Add an idempotency key to the create path so the same logical request creates one ticket even if the tool fires twice.

Requirements:

  • A caller-supplied key maps to the ticket it created
  • Replaying the same key returns the original ticket, not a new one
  • Calls with no key still behave as before
  • Validation still runs on the first (non-replayed) call
  • Prove a retried call yields the same id and the same object

💡 Hint: Keep a key → ticket map; on a seen key, return the stored ticket immediately before doing any write.

Show solution

An idempotency key makes the write safe to retry — the same key returns the same ticket, never a duplicate:

_IDEMPOTENT = {}      # key -> ticket

def create_idempotent(title, priority="normal", idem_key=None):
    if idem_key is not None and idem_key in _IDEMPOTENT:
        return _IDEMPOTENT[idem_key]          # replay: return the original
    t = validated_create(title, priority)
    if idem_key is not None:
        _IDEMPOTENT[idem_key] = t
    return t

a = create_idempotent("db slow", "high", idem_key="req-42")
b = create_idempotent("db slow", "high", idem_key="req-42")   # retried
print(a["id"], b["id"], a is b)      # same id, same object -> no duplicate

Without idempotency, a client timeout-and-retry silently creates two tickets. The key turns "at-least-once" delivery into "effectively-once" — essential for any tool that mutates state.

Exercise 5 · Six offline server tests, no key neededProfessional

Context: A server has to be testable in CI with no live client and no API key. Because the logic lives in a pure backend, a fast deterministic suite can cover every behaviour the MCP decorators merely expose.

Your task: Write six pytest-style tests over the pure backend covering create, read, validation rejection, idempotency, the prompt, and not-found.

Requirements:

  • Tests run with plain pytest -q and no key or network
  • Cover the happy create/read path
  • Assert a bad priority is rejected
  • Assert a replayed idempotency key returns the same object
  • Assert the triage prompt and the not-found case behave
  • No test spawns the MCP server

💡 Hint: Call the backend functions directly; the MCP decorators add no logic worth a runtime, so exercising validated_create / get_ticket is full coverage.

Show solution

Testing the pure backend gives full coverage of behavior with zero MCP runtime — fast and deterministic in CI:

# tests/test_server.py -- runnable with: pytest -q  (no API key)
def test_create():        assert validated_create("a")["status"] == "open"
def test_read():
    t = validated_create("b"); assert get_ticket(t["id"])["title"] == "b"
def test_bad_priority():
    try: validated_create("c", "nope"); assert False
    except ToolError: pass
def test_idempotent():
    x = create_idempotent("d", idem_key="k1")
    y = create_idempotent("d", idem_key="k1"); assert x is y
def test_prompt():        assert "Triage" in triage_prompt(1)
def test_not_found():     assert "error" in get_ticket(9999)

for fn in [test_create, test_read, test_bad_priority,
           test_idempotent, test_prompt, test_not_found]:
    fn()
print("6 tests passed")

Because the logic lives in backend.py, these run without spawning the server. The MCP decorators are a thin shell you can smoke-test separately once registered.

Exercise 6 · Register with Claude and plan a phased rollout with authIndustry scenario

Context: Going from a local stdio server to a remote, authenticated one is a risk ramp, not a flip of a switch. Registration is a single JSON block; the rollout is staged so write access is never exposed unauthenticated.

Your task: Produce the claude_config.json registration for local stdio and model a phased rollout: read-only local → gated write tools → remote with auth over HTTP+SSE.

Requirements:

  • The config registers the server by command/args for local dev
  • A phase function maps (remote, writes_enabled, authed) to a rollout stage
  • Phase 1 is read-only local; phase 2 adds gated writes still local
  • Phase 3 is remote HTTP+SSE and requires auth
  • Enabling writes on an unauthenticated remote transport is explicitly blocked

💡 Hint: Encode the one non-negotiable rule as a guard: if the transport is remote and writes are on but auth is off, return a BLOCKED state rather than a valid phase.

Show solution

Registration is one JSON block; the rollout is a risk ramp from read-only local to authenticated remote:

# claude_config.json -- stdio registration for local dev
CONFIG = {
  "mcpServers": {
    "ticketing": {"command": "python", "args": ["server.py"]}
  }
}
import json; print(json.dumps(CONFIG, indent=2))

def rollout_phase(remote, writes_enabled, authed):
    if not remote and not writes_enabled:
        return "phase 1: read-only, local stdio (lowest risk)"
    if not remote and writes_enabled:
        return "phase 2: gated write tools, still local"
    if remote and authed:
        return "phase 3: remote HTTP+SSE, auth required (least privilege per client)"
    return "BLOCKED: remote server must require auth before enabling writes"

for r, w, a in [(False,False,False),(False,True,False),
                (True,True,True),(True,True,False)]:
    print(rollout_phase(r, w, a))

Never expose write tools on an unauthenticated remote transport — that last BLOCKED branch is the guardrail. Local stdio is safe by default (the client owns the process); remote servers need auth and per-client least privilege before any mutating tool is turned on.

✓ You are done when…

  • The server defines a tool, a resource, and a prompt.
  • The write tool validates its input and is narrow, not "run anything".
  • python -m pytest tests/ -v shows 6 passed.
  • The config registers the server with Claude Code over stdio.
📁 Your finished folder
mcp-server/
├─ .venv/
├─ requirements.txt
├─ backend.py           (in-memory ticket store)
├─ server.py            (tool + resource + prompt)
├─ claude_config.json   (stdio wiring for Claude Code)
└─ tests/
   └─ test_server.py    (6 offline tests)
📋 Staff-level self-scoring — is this MCP server safe to expose to any client?
DimensionMeets the barAbove the bar
Tool contract is clearEach tool/resource/prompt has a documented shape, typed & validated params, and a one-line purpose.The contract is versioned; deprecations are graceful; a client can integrate from the docs alone.
Least-privilege toolsTools are narrow and purpose-built — no 'run any SQL' or arbitrary file/shell; read and write are separated.Every tool's blast radius is stated; capabilities map to a real need, not convenience.
Trust boundary enforcedRemote/write-capable servers require auth (tokens/OAuth) + TLS; write tools are gated/confirmed.Unauthorized calls are tested and rejected; per-client scoping holds under an attempted-escalation test.
Untrusted resource dataResource content is treated as untrusted; embedded instructions are not blindly executed (injection-aware).An injection planted in a resource is tested and cannot drive the model into an unintended tool call.
Errors are structuredTools return the documented shape and surface failures as structured errors, not stack traces.Error paths are unit-tested; timeouts/partial failures degrade cleanly with an actionable message.
Tested at two layersTools/resources are unit-tested like an API (params, auth, contract).Agent-level evals prove a model actually selects and calls the tools correctly across >1 MCP client.

Score each row 0 (missing) / 1 (meets) / 2 (above). 0–4: a prototype — keep building. 5–8: a solid build you could take to review. 9–12: staff-level — production-defensible. Any dimension at 0 blocks shipping regardless of the total.

Knowledge check check yourself

✓ Knowledge check

An MCP server exposes three primitives — tools, resources, and prompts. What is the core distinction between them?

Show answer
A tool performs an action and may have side effects (gate the risky ones); a resource is data the model reads, read-only by design; a prompt is a reusable template every client can share. Modeling each capability as the right primitive is the key design skill.
✓ Knowledge check

Why is 'every tool you expose is reachable by every connected client' treated as both the point and the danger of an MCP server?

Show answer
Generalizing access is the value — build the integration once, every client benefits — but it also means a too-powerful tool (raw SQL, arbitrary file write) becomes a capability handed to any agent that connects. So tools must be narrow, validated, least-privilege, and writes gated; the blast radius is wider than a single app.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in