Part 2 of 7 · 5 min read
Build in layers, so each layer can be tested alone
Covers: Build in layers, so each layer can be tested alone; The tool surface.
Build in layers, so each layer can be tested alone
The server has three layers, and almost every testing decision follows from the separation.
| Layer | Files | What it knows | How it is tested |
|---|---|---|---|
| Data and rules | db.py, service.py |
tickets, who may see what, the state machine (open, closed), idempotency | plain function calls, no MCP at all |
| Tool adapter | tools.py |
names, descriptions, schemas, how a rule's failure becomes words for a model | lint, snapshot, protocol checks |
| Transport and identity | app.py, stdio_main.py, world.py |
how bytes arrive, who is calling | child process, real socket, tokens |
The service never mentions MCP; the adapter never contains a business rule. Keep it that way. When "closing a closed ticket" is a rule, it lives in Desk.close, and a one-line unit test pins it. The adapter only turns the service's failure into text. If you put the rule in the adapter, the only way to test it is through the protocol, which is slower and harder to read when it fails.
The database does some of the work too. The schema has CHECK constraints on status and priority and a unique index on the idempotency key. A constraint is a test that runs on every write, in production. The experiments later show one defect that only a database constraint can really prevent.
The users and who may do what
Three kinds of user (module 5's authorization server issues them tokens): a customer (carol) who may read and comment on her own tickets; support staff (alice) who may do everything on any ticket; an administrator (dave) who may also export. Two layers enforce this, on purpose: the token's scopes (tickets:read, tickets:write, desk:admin) are checked before the tool runs, and the service then checks the record (is this ticket yours?). The security section shows a case where only the second layer stands between a customer and a closed ticket.
The tool surface
Eight tools, each with one job:
| Tool | Reads or writes | Needs |
|---|---|---|
tickets_search |
reads | tickets:read |
ticket_get |
reads | tickets:read |
ticket_create |
writes (idempotent) | tickets:write |
ticket_comment |
writes | tickets:write |
ticket_close, ticket_reopen, ticket_assign |
writes, staff only | tickets:write |
desk_export |
reads everything, admin only | desk:admin |
The design rules come from module 1; here is how they show up in this server.
Descriptions say what, when and what comes back. Every description has three parts: what the tool does, when to use it (and often when to use another tool instead), and what it returns. For example, ticket_close reads: "Close an open ticket with a one-sentence resolution. Staff only. Use this when the problem is solved or answered; to undo it call ticket_reopen. Returns the closed ticket." Naming the neighbouring tool is deliberate: it tells the model where to go next, and the lint rule in section 6 checks that each description contains a "Use this ..." sentence and a "Returns ..." sentence.
Every parameter is described, and closed sets are enums. priority is low, normal, high or urgent in the schema, so a misspelling is rejected by the schema before any code runs. additionalProperties: false means a misspelled argument name is an error, not silently ignored. (For a tool with no parameters, the specification recommends exactly {"type": "object", "additionalProperties": false}.)
Results have an outputSchema. The specification says that if a server declares one it must return structured results that match it, and that clients should validate. It also asks that a tool returning structured content should also return the same JSON as text, for older clients. The example's conformance suite checks both. The output schemas are closed too (no extra fields), which is how a leaked field gets noticed (section 7).
Annotations are honest, and a test checks them. readOnlyHint is true for three tools and false for the rest. The specification warns that clients must treat annotations as untrusted unless the server is trusted, so an annotation is a claim, and a claim can be tested: the suite calls every tool marked read-only and checks that the database did not change.
Writes that can be retried take an idempotency key. ticket_create accepts a key; sending the same key again returns the same ticket and created: false. A model that retries after a timeout then cannot file the same ticket twice. Module 4 showed the same idea for one-time actions.
Lists are paged, and capped. tickets_search returns at most 20 tickets, with total and a next_cursor. One tool does not follow this rule, and I left it that way on purpose: desk_export returns everything, which is the subject of section 13.
Design choices I made, and where the specification is silent
- Listing is the same for everyone. The specification allows
tools/listto vary by the authorization presented (for example, hiding tools the caller's scopes cannot use) but forbids varying it per connection or as a side effect of other requests. My server lists all 8 tools to every caller and lets a call fail with403. Hiding tools is a reasonable alternative (Medium exercise 1 touches on it). - No per-ticket resources. The resources and prompts in the example (a policy text and a triage prompt) are public. I did not expose
desk://tickets/{id}as a resource, because my server's resource handler has no caller identity, so it could not apply the ownership rule. A resource that bypasses your access control is a hole, not a convenience. - Tool names follow my own house rule (lower snake case, 3 to 41 characters). The specification is looser: names should be 1 to 128 characters from letters, digits, underscore, hyphen and dot, and unique within a server.