Capstone: a production MCP server
A complete, tested server for the practice shop.
Create a free account Try part of it here
Why it matters
This capstone assembles the phase into one server for the practice shop: the JSON-RPC dispatcher, version and metadata checks, tools with schemas and teaching errors, resources with a template, a prompt, paging and caching hints, stdio framing and the conformance probes. It's the shape of a real server you could publish, and building it end to end is the best check that the pieces fit.
What you'll be able to do
- Plan an MCP server: which tools, resources and prompts, and how each is described
- Wire the pieces of this phase into one dispatcher that follows the 2026-07-28 rules
- Serve it over stdio framing and test it end to end through a client
- Pass a conformance suite and review the server against a security checklist
What it covers
- The brief
- A teaching error for a wrong sku
- Review checklist
- Nothing may change data
- One request, all the way through
- Getting the order right
- Writing the README
The brief
"Our support team wants an MCP server for the shop. Agents should look up orders and stock, read the help center, and staff should have a one-click 'draft a reply' prompt. Nothing may change data yet."
The plan
Start by sorting each need into the primitive that fits. Things the model reaches for are tools, things people attach are resources, and things staff pick from a menu are prompts:
| primitive | name | why |
|---|---|---|
| tool | get_order(order_id) | read-only; returns status, items and total as structured content |
| tool | check_stock(sku) | read-only; teaching error listing known skus when it's wrong |
| resource | docs://help/{id} (template) + listed articles | the app or user attaches articles as context |
| prompt | draft_reply(order_id, tone?) | staff pick it; it fills the order into a tuned request |
Both tools carry readOnlyHint: true, which the brief allows: nothing writes. Every result is "complete", and lists carry ttlMs and cacheScope.
The architecture
Think of a mailroom. One letter comes in, a clerk checks it and sends it to the right department, and one reply goes out:
stdin ──▶ serve(): one JSON line in ──▶ dispatch(msg) ──▶ one JSON line out ──▶ stdout
│
checks: shape, _meta, version │ routes by method
├── server/discover
├── tools/list ──────▶ tool registry (schemas from type hints)
├── tools/call ──────▶ validation, results, isError
├── resources/list, resources/templates/list, resources/read
└── prompts/list, prompts/get
Every piece is from an earlier lesson. The new work is wiring: one dispatcher that applies the checks in the right order and wraps every answer correctly.
A teaching error for a wrong sku
The brief asks for a teaching error when a sku is wrong. Compare what the model gets from each version. It can only recover from one of them.
STOCK = {"TENT-2": 14, "STOVE-1": 3, "LAMP-4": 0}
def bad_check_stock(sku):
if sku not in STOCK:
return {"isError": True, "content": [{"type": "text", "text": "Error 500"}]}
return {"content": [{"type": "text", "text": f"{STOCK[sku]} in stock"}]}
def check_stock(sku):
if sku not in STOCK:
known = ", ".join(sorted(STOCK))
return {"isError": True, "content": [{"type": "text", "text": f"No sku {sku}. Known skus: {known}."}]}
return {"content": [{"type": "text", "text": f"{STOCK[sku]} in stock"}],
"structuredContent": {"sku": sku, "stock": STOCK[sku]}}
print(bad_check_stock("TENT2")["content"][0]["text"])
print(check_stock("TENT2")["content"][0]["text"])
print(check_stock("TENT-2")["structuredContent"])With the second message, the model can see that it dropped a hyphen, and it fixes that on its next try without bothering anyone. You'll write this kind of error in the exercise.
Show the answer
Shape (JSON-RPC), then required _meta, then protocol version, then route by method
Malformed messages are rejected before anything else, and every method depends on the version and metadata being valid.
Review checklist
Before you hand over the keys to a new house, you walk through every room. Before publishing, walk the server through what this phase taught:
- Protocol:
server/discoverworks; unknown methods give -32601; missing_metagives -32602; unknown versions give -32022 withsupported; notifications get no reply; ids are echoed. - Tools: task-shaped, described for the model, strict schemas, concise structured results, teaching
isErrormessages, annotations that tell the truth, a fixed order. - Resources and prompts: a template instead of a giant list; -32602 for missing resources; prompt arguments validated and filled safely.
- Transport: nothing but protocol on stdout; logs on stderr; compact one-line JSON.
- Security: read-only credentials for a read-only server; no secrets in descriptions or results; input validated; no token passthrough when it goes remote.
- Shipping: conformance probes in CI; semantic versioning; listed in the registry when public.
Nothing may change data
The brief says nothing may change data yet. Don't rely on memory for a rule like that. Make it a check that fails the build.
tools = [
{"name": "get_order", "annotations": {"readOnlyHint": True}},
{"name": "check_stock", "annotations": {"readOnlyHint": True}},
{"name": "cancel_order", "annotations": {"readOnlyHint": False}},
]
def writers(tools):
return [t["name"] for t in tools if not t.get("annotations", {}).get("readOnlyHint")]
found = writers(tools)
print("tools that may change data:", found)
print("review", "FAILS" if found else "passes")Someone added cancel_order, and this check catches it before release. Annotations are hints to the client and don't enforce anything, so the handlers must be read-only too. But a check like this stops the easy mistakes.
Walk your server through these before anyone else connects to it.
Show the answer
One template describes thousands of help articles without listing each one
A template like docs://help/{id} names a whole family of resources. The server lists a few useful ones and resolves the rest on demand.
One request, all the way through
A miniature of the capstone: a request line goes in, passes the checks, is routed, and comes out as one line.
import json PV, CAPS = "io.modelcontextprotocol/protocolVersion", "io.modelcontextprotocol/clientCapabilities" SERVER_INFO = {"name": "mini", "version": "0.1.0"} def dispatch(msg): if "id" not in msg: return None meta = msg.get("params", {}).get("_meta", {}) if PV not in meta or CAPS not in meta: return {"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32602, "message": "missing _meta"}} if msg["method"] == "server/discover": result = {"supportedVersions": ["2026-07-28"], "capabilities": {"tools": {}}} return {"jsonrpc": "2.0", "id": msg["id"], "result": {"resultType": "complete", **result, "_meta": {"io.modelcontextprotocol/serverInfo": SERVER_INFO}}} return {"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32601, "message": "Method not found"}} line_in = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": {PV: "2026-07-28", CAPS: {}}}}) line_out = json.dumps(dispatch(json.loads(line_in)), separators=(",", ":")) print(line_out)
The capstone's dispatcher is this, grown up: more checks, and routes to the tool registry, resources and prompts. Because it's a plain function, the conformance suite and the stdio loop can both drive it directly.
def classify(msg): if "id" not in msg: return "notification: no reply" if "_meta" not in msg.get("params", {}): return "error -32602" return "route " + msg["method"] print(classify({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}))
Show the answer
error -32602
It has an id, so it's a request, but its params have no _meta, so classify returns error -32602. That's checked before routing, so it never reaches tools/list.
Getting the order right
The order your dispatcher checks things in isn't arbitrary, and getting it wrong causes bugs that are easy to miss.
An airline checks your passport before it looks for your flight. Check the protocol version and metadata first, then route by method. If you route first, an unknown method gets "method not found" even when the real problem is that the client speaks a version you don't support. The client then looks for the wrong fix.
def dispatch(msg, methods, versions=("2026-07-28",)): meta = msg.get("params", {}).get("_meta", {}) if "protocolVersion" not in meta: return -32602, "missing _meta" if meta["protocolVersion"] not in versions: return -32022, "unsupported version" if msg["method"] not in methods: return -32601, "method not found" return 0, "ok" methods = {"tools/list"} print(dispatch({"method": "tools/list", "params": {}}, methods)) print(dispatch({"method": "nope", "params": {"_meta": {"protocolVersion": "1999"}}}, methods)) print(dispatch({"method": "nope", "params": {"_meta": {"protocolVersion": "2026-07-28"}}}, methods))
Nothing may escape the dispatcher
One unhandled exception in a handler can end the whole connection, cutting off every other request in progress. So wrap the call to each handler. Log the details on your side, and return an internal error with no details in it.
Read-only now, safe later
This server changes nothing, which is what the brief asks for. When you add tools that write, like refunds, you'll want a confirmation from the user before anything happens, as in the multi-round-trip pattern you learned earlier in this phase. Don't rely on the model to ask first, since a prompt injection could talk it out of that.
Test beyond your own setup
A recipe that works in your oven may burn in someone else's. If you've only tried your server in one host, you've tested that host as much as your server. Hosts differ in what they send and how strictly they read replies. Your protocol tests and conformance probes run against the spec rather than one app. Run them in CI, and then try at least two hosts by hand before you publish. It's a small amount of work compared with a broken release.
Writing the README
A server isn't finished until someone else can install it. Think of the label on a medicine bottle: what it's for, how to take it and when not to. Your README should say what the server does, list every tool with what it needs, show the exact config to add to a host, and name any environment variables. Include one example of a real question it can answer.
Say plainly what it can't do, too. "Read-only: it can't change orders" is exactly what a security reviewer wants to see. It also tells users what to expect from their agent, and it saves you from support questions you'd otherwise answer one at a time. Keep it up to date, because a wrong README is far worse than a short one, and it costs little to keep right.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
Why are both of the support server's tools annotated readOnlyHint: true?