AI EngineeringZero to ProductionHome·About·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

"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.

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

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

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

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

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

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

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

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_ticketread-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