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.
pip install mcp+ an MCP-aware client (e.g. Claude Code) to connect the server
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
| Candidate | MCP 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 |
2 · Architecture advanced
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 agentyou 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), andPrompts(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
| Risk | Control |
|---|---|
| 🔴 A destructive tool invoked by any connected client | Least-privilege tool design; separate read vs write; require confirmation/gating on write tools (Ch 4/6) |
| 🔴 Unauthenticated access to a remote server | Auth on the transport (tokens/OAuth); never expose a write-capable server unauthenticated |
| 🟠 Prompt injection through resource content | Treat 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 clients | Version the server; document the tool contract; deprecate gracefully |
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}"
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.
mcp = FastMCP("my-company-tools")creates the server and gives it a name. Everything you publish attaches to thismcpobject.@mcp.tool()abovecreate_ticketpublishes an action — something the model can do. It takes inputs (title,priority) and returns a result.@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.@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.
5 · Transport, auth & deployment advanced
| Choice | Use when | Note |
|---|---|---|
| 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 scoping | Always | 🟢 per-client tool/resource permissions |
6 · Evaluation expert
| Eval | Measures |
|---|---|
| Tool-call correctness | Does Claude invoke the right tool with valid params for a task? |
| Contract conformance | Tools return the documented shape; errors are structured |
| Client interop | Works across Claude Code, the API, and another MCP client |
| Auth & permission tests | Unauthorized calls are rejected; scoping holds |
| Safety of write tools | Destructive tools require gating; no unintended side effects |
7 · Phased rollout expert
Skills & course map expert
| Skill | Learn it in |
|---|---|
| MCP protocol & primitives | T3 |
| Claude + MCP in practice | C4 |
| MCP ecosystem & integrations | I1 |
| Tool design & the agent loop | Ch 4 |
| Auth, deployment, versioning | Ch 6 · O3 |
| Injection defense on resources | I5 |
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
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 ...
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.
mkdir -p mcp-server/testsmakes the project folder (and atestssub-folder in one go);cd mcp-servermoves you into it so the later steps land in the right place.python3 -m venv .venvcreates a virtual environment — a private copy of Python for this project.source .venv/bin/activateswitches it on (the comment shows the different Windows command).pip install "mcp[cli]" pytestdownloads the MCP SDK (the real library this server is built on) andpytest(the test runner used in Step 4).pip freeze > requirements.txtwrites 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.
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.
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
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.
class Storebundles 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, andself._next = 1is the counter for the next id.create(...)makes a new ticket: it turns the counter into a string id, bumps the counter, builds a dictionary with the ticket's fields (defaultingstatusto"open"), stores it, and returns it.get(tid)looks a ticket up by id. If the id isn't there itraises aKeyError— failing loudly on missing data rather than returning something wrong.store = Store()at the bottom creates one shared instance. The server and the tests both import this samestore, 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.
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
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.
from backend import storepulls in the shared fake store from Step 2, andmcp = FastMCP("ticketing")creates the named server.@mcp.tool()publishescreate_ticket— the write action. Because writes are risky, it checks its inputs first: theprioritymust be one ofVALID_PRIORITIES(low/med/high) and thetitlemust not be blank — otherwise itraises aValueError. Only then does it callstore.create(...).@mcp.resource("ticket://{ticket_id}")publishesget_ticket— read-only. A client asks forticket://5and gets back a formatted one-line summary; it can't change anything.@mcp.prompt()publishestriage, a shared template that wraps a ticket in a standard instruction any client can reuse.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.
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.
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")
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.
- The SDK decorators wrap your functions, so
server.create_ticketis no longer the plain function. The_create,_getand_triagehelpers usegetattr(..., "fn", ...)to reach the original function underneath (the comment gives the fallback for other SDK versions). test_create_ticket_returns_idmakes a ticket and asserts it has an id and the right priority — the happy path works.test_invalid_priority_rejectedandtest_empty_title_rejectedusewith pytest.raises(ValueError):to assert the tool refuses bad input. Here a passing test means the code correctly threw an error.test_get_ticket_reads_backcreates then reads a ticket to prove the resource returns it;test_get_unknown_ticket_errorschecks a missing id raisesKeyError;test_triage_prompt_includes_ticketchecks 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.
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
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.
python -m pytest tests/ -vtells pytest to look in thetests/folder. The-v(verbose) flag makes it list every test by name instead of just printing dots.- Each line ends in
PASSED— pytest ran that test function and its assertions all held. - 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.
| Test | Proves |
|---|---|
| create returns id | the write tool works against the backend |
| invalid priority rejected | params are validated — no free-text writes |
| empty title rejected | the tool guards its own inputs |
| get reads back | the resource returns the created ticket |
| unknown ticket errors | missing data fails cleanly, not silently |
| triage includes ticket | the 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.
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?
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.
"mcpServers"is the section Claude Code reads to find MCP servers. Inside it,"ticketing"is the name you're giving this one."command": "python"plus"args": ["server.py"]is literally the instruction "to start this server, runpython server.py" — exactly the file you wrote in Step 3.- Because
server.pyuses 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.
Troubleshooting — every error you might hit expert
| What you see | What it means & the fix |
|---|---|
No module named mcp | venv 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: server | Run pytest from inside mcp-server/. |
| Claude Code doesn't see the server | Check the config path/command; restart the client; confirm mcp.run() is at the bottom of server.py. |
| Model calls the wrong tool | Improve 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.
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.
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.
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.
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.
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 -qand 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.
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/ -vshows 6 passed.- The config registers the server with Claude Code over stdio.
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)
| Dimension | Meets the bar | Above the bar |
|---|---|---|
| Tool contract is clear | Each 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 tools | Tools 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 enforced | Remote/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 data | Resource 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 structured | Tools 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 layers | Tools/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
An MCP server exposes three primitives — tools, resources, and prompts. What is the core distinction between them?
Show answer
Why is 'every tool you expose is reachable by every connected client' treated as both the point and the danger of an MCP server?