Testing, designing and shipping servers
Conformance checks, tool design for models, SDKs and deployment.
Create a free account Try part of it here
Why it matters
A server that works with one host on your laptop isn't done. It has to work with every host, keep working as the protocol evolves, and actually help models get tasks done. That last part is a design problem as much as a coding one. This lesson covers how professionals test servers, the tool design habits that separate useful servers from frustrating ones, and how servers get packaged, found and run.
What you'll be able to do
- Test a server at three levels: handlers, full JSON-RPC exchanges, and conformance probes
- Design tools for models: task-shaped, well described, concise and recoverable
- Package and distribute a server, and know the official SDKs and the MCP Registry
- Deploy a stateless remote server that scales, and observe it in production
What it covers
- Three levels of testing
- Designing tools for models
- Two tool designs, compared
- Packaging and finding servers
- Two probes against the practice server
- Renames break things
Three levels of testing
A car maker tests each part, then the assembled engine, then runs a standard road test that every car must pass. Servers get the same three levels:
- Handler tests. Each tool is a function, so test it like one: inputs in, result out, with fakes for the APIs it calls. Fast, and most of your tests.
- Protocol tests. Feed whole JSON-RPC messages to the server through an in-memory transport and check the replies: ids echoed, errors coded correctly, notifications unanswered. This catches the mistakes that only appear when a real client connects.
- Conformance probes. A fixed battery of checks any MCP server should pass, regardless of its tools: does
server/discoverwork, does an unknown method give -32601, does a missing_metagive -32602? Run them in CI, so a refactor can't silently break compatibility.
For poking at a server by hand, the MCP Inspector is a developer tool that connects to a server and lets you browse and call its tools, resources and prompts in a browser:
npx @modelcontextprotocol/inspector uv run shop_server.py
Finally, take it for a test drive: a real host, with a real model, on real tasks. That's where you learn whether the tools are well designed.
def handle(msg): if msg.get("method") == "ping": return {"id": msg["id"], "result": {}} return {"id": msg["id"], "error": {"code": -32601}} print(handle({"id": 9, "method": "nope"})["error"]["code"])
Show the answer
-32601
The method isn't "ping", so the reply carries the error, and ["error"]["code"] reads -32601. A protocol test feeds in a whole message and checks the reply's error code.
Each level catches mistakes the one before it can't see.
Show the answer
Protocol tests: whole JSON-RPC messages through an in-memory transport
Handler tests call tool functions directly, so they never see notifications. Protocol tests check the message handling itself.
Designing tools for models
A model only knows your tools through their names, descriptions and schemas, and it pays in tokens for every result. Ask a waiter what's good tonight and you want a recommendation, not the supplier list. The habits that work:
- Shape tools around tasks, not endpoints. Don't mirror an API's 40 endpoints as 40 tools. A model asked "how's the spring campaign doing?" does better with one
campaign_report(name)tool than with a chain oflist_campaigns,get_campaignandget_metricscalls. - Name and describe for the model. Say what it does, when to use it, and what comes back. Describe each parameter's format with an example: "ISO date, e.g. 2026-10-30".
- Constrain inputs. Use enums, minimums and required fields, so wrong calls fail fast with a clear message instead of doing something odd.
- Return what's useful, concisely. Long raw JSON wastes context and buries the answer. Return the fields that matter, paginate long lists, and consider a
detailparameter ("summary" or "full"). Include ids the model will need for follow-up calls. - Make errors teach. "No campaign named 'sprng'. Did you mean 'Spring sale'?" gets fixed on the next call. Use
isErrorresults for these, not protocol errors. - Keep the list small and stable. Fewer, distinct tools are easier for a model to choose between. A fixed order helps caching.
- Test with a model. Write a few realistic tasks, run them, and read the transcripts. The model's confusion shows you which description to fix. That's an eval, as in Phase 1.
In plain words: design tools for what people ask, and read how the model uses them.
Two tool designs, compared
Tools that mirror your API make the model do the joining. Count what it takes to answer "where's Ann's order?" with each design.
CUSTOMERS = {"ann@shop.example": "c1"}
ORDERS = {"c1": ["o9"]}
STATUS = {"o9": "shipped"}
calls = []
def find_customer(email):
calls.append("find_customer"); return CUSTOMERS[email]
def list_orders(customer):
calls.append("list_orders"); return ORDERS[customer]
def get_order(order):
calls.append("get_order"); return STATUS[order]
status = get_order(list_orders(find_customer("ann@shop.example"))[0])
print("API-shaped:", status, "after", len(calls), "tool calls")
calls.clear()
def latest_order_status(email):
calls.append("latest_order_status")
return STATUS[ORDERS[CUSTOMERS[email]][0]]
print("Task-shaped:", latest_order_status("ann@shop.example"), "after", len(calls), "tool call")Each extra call is another trip to the model, with more waiting, more tokens and another chance to go wrong. Design tools around what people ask for, not around your database tables.
Show the answer
A handful of task-shaped tools (find_contact, log_call, deal_summary) with clear descriptions
Fewer, higher-level tools mean fewer choices, fewer calls per task, and less context spent on definitions.
Packaging and finding servers
- Python servers are usually published to PyPI and run with
uvx shop-mcp, which installs and runs them in an isolated environment. Node servers are published to npm and run withnpx. Many are also published as container images. - The MCP Registry is the community's central directory of public servers, a phone book that hosts and people can search. Publishing there gives yours a stable name and metadata.
- Version your server with semantic versioning, numbers like 1.4.2 whose first part goes up when something breaks. Changing a tool's name, removing a parameter or changing what it returns is a breaking change for every host and every prompt that relies on it.
- The official SDKs (Python and TypeScript are the most used, and there are others, such as C#, Java, Kotlin, Go and Rust) handle framing, transports, schemas and protocol versions. They're how most servers are built. Knowing what they do inside, which is what this phase has taught, is how you debug them.
Running a remote server
Modern MCP is stateless, which makes deployment ordinary web engineering:
- Run several copies behind a load balancer, with no sticky sessions. Any copy can answer any request.
- Put OAuth in front (last lesson), plus rate limits per user and per tool.
- Use
ttlMson list results, so clients don't hammertools/list. - Log to stderr (or your platform's logs) with a request id. Clients can pass OpenTelemetry trace context (
traceparent) in_meta, so one trace can follow a request from the host through your server to the APIs it calls. - Watch per-tool error rates and latency. A tool that fails 30% of the time is a design bug.
Two probes against the practice server
Conformance probes are ordinary requests with expected answers. Here are two, run against the live practice server.
import requests URL = "https://api.gradient.test/mcp" GOOD = {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}} def post(method, meta, id=1, version="2026-07-28"): body = {"jsonrpc": "2.0", "id": id, "method": method, "params": {"_meta": meta}} headers = {"MCP-Protocol-Version": version, "Mcp-Method": method} return requests.post(URL, json=body, headers=headers, timeout=10) r = post("resources/unknown", GOOD) print("unknown method:", r.status_code, r.json()["error"]["code"], "(want 404, -32601)") r = post("tools/list", {**GOOD, "io.modelcontextprotocol/protocolVersion": "1999-01-01"}, version="1999-01-01") err = r.json()["error"] print("bad version:", r.status_code, err["code"], err["data"]["supported"], "(want 400, -32022)")
Each probe sends one request and compares the answer with what the spec requires. A conformance suite is a few dozen of these, run against any server. The exercise builds one that works on any in-memory server.
Show the answer
Protocol behavior every client relies on: error codes, ids, notifications, discovery and versions
Handler tests check your tools; conformance checks your server speaks MCP correctly, whatever its tools are.
Renames break things
Rename a street, and every map and delivery route that used the old name goes wrong. A tool's name is a public interface. Models, prompts and saved approvals all depend on it, so treat a rename as a breaking change.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What is the MCP Inspector for?