Testing and debugging a server

Lesson 4 of 5 in Building with MCP: Servers, Clients, and the Install Review.

An MCP server has an unusual testing problem: it has two consumers with nothing in common. One is a JSON-RPC client, which is deterministic, well-documented, and easy to assert against. The other is a language model, which reads your prose, invents arguments under uncertainty, and will happily do something reasonable-but-wrong at 3 a.m.

Test both, at three separate layers, and always reproduce a bug at the lowest layer that still shows it. Half of all “the agent is broken” reports collapse the moment someone calls tools/call by hand with the exact arguments from the trace.

Layer 1 — protocol conformance

Does the server behave like an MCP server, regardless of what it does?

  • server/discover answers with the protocol versions, capabilities and identity you expect, for every revision you claim to support.
  • tools/list, resources/list, prompts/list return well-formed declarations; names fit [A-Za-z0-9_.-] and are unique.
  • Declared outputSchema and returned structuredContent actually agree — validate the result against your own schema in the test, since clients SHOULD do exactly that in production.
  • Over HTTP: the MCP-Protocol-Version header matches the _meta field on every POST, and a deliberate mismatch produces 400 with -32020.
  • Unknown tool names produce a protocol error; failing executions produce isError: true inside a result.

These are ordinary assertions. Automate them in CI and they never regress.

Layer 2 — handler behaviour

Does the code do the right thing, including when the caller lies?

  • Feed arguments that violate the schema — wrong types, missing required fields, a limit of 10,000, a service name with a path traversal in it. The handler must reject them itself. Schema validation upstream protects you from well-behaved clients only.
  • Feed arguments that pass the schema but should be refused by policy: another tenant’s incident ID, a severity the caller has no access to. Access control is a server MUST.
  • Check rate limiting and output sanitization, both also server MUSTs.
  • Check handle lifecycle if the server is stateful: an expired handle, a handle minted for another caller, a handle reused after a transaction closed.

This is where you write ordinary security tests. Nothing here involves a model.

Layer 3 — model-in-the-loop

Given only your declarations, does a model use the server correctly?

  • Write ten to twenty realistic task prompts and run them through a real host. Score: did it pick the right tool, fill the arguments correctly, recover from an isError result, and stop?
  • Watch specifically for the failure the first two layers cannot see: a tool that is technically perfect and semantically ambiguous, so the model reaches for it in the wrong situations.
  • Include the negative cases. If your description says the tool does not close incidents, one of your tasks should ask the agent to close an incident and confirm it does not try.
  • Run each task several times. One pass proves nothing under non-determinism — you are measuring a rate, not checking a box.

This layer is an eval, not a test, and it is the layer teams skip.

One heuristic carries most of the debugging work: when the model misuses your tool, suspect the declaration, not the handler. The handler has tests. The declaration has prose — and prose is the only thing the model reads. “Wrong tool chosen” and “arguments invented” are almost never handler bugs; they are description bugs wearing a handler’s coat.

The symptom table below is worth keeping next to your first server.

The model never calls the tool at all

Check, in order: does tools/list actually include it (call it directly); is the host filtering or requiring per-tool consent; and does the description say when to use it in the words a user would use? A tool described as “Queries the incident index” will lose every time to a tool described as “Use when the user asks about past outages or current incidents.”

It calls the tool, but with invented or malformed arguments

The schema is under-specified. Add enum where the values are closed, pattern or a format example where the shape matters, minimum/maximum where the range matters, and a one-line description on every property. The model fills gaps with plausible guesses; your job is to leave fewer gaps. Then confirm the handler rejects the malformed version anyway.

HTTP 400 with JSON-RPC error -32020

HeaderMismatch: the MCP-Protocol-Version header and the _meta.io.modelcontextprotocol/protocolVersion field in the body disagree. Usually a client that hardcodes one and derives the other, or a proxy rewriting headers. Check the Mcp-Method and Mcp-Name mirror headers too.

It works when I drive the server by hand, but fails inside the host

You have found a layer boundary, which is good news. Suspect the host: tool-name collisions with another connected server (names are unique per server only), a consent prompt nobody answered, a context budget that truncated your declarations, or protocol-version skew between what the host speaks and what your server advertises.

The second call fails — the server seems to have forgotten

Expected, under revision 2026-07-28: there is no protocol session. Either the server should be returning an explicit handle from a creation tool and taking it as an argument later, or the model was never told to pass the handle back — a declaration bug again. Verify the handle is authorized on every call, not just minted once.

Tool-list changes never reach the client

Change notifications in this revision are opt-in and best-effort: the client opens a long-lived subscriptions/listen stream naming the notification types it wants, the server acknowledges the accepted subset, and every notification carries its subscription ID. Delivery is not guaranteed across reconnects — clients should still poll. If you designed a feature that assumes reliable push, redesign it.

One tool failure ends the whole run

You are raising protocol errors for business failures. Move recoverable failures into the result as isError: true with a message the model can act on — “no incident matched; try a broader query or a different service name.” Clients SHOULD hand that to the model, which turns a dead run into a retry.

Tool: Trace Debugger — Practise the lowest-layer-first discipline on real traces: find the step where an MCP-backed agent went wrong, and decide whether the bug lives in the declaration, the handler, or the host.

Interactive checkpoint quiz (2 questions) — open this page in a browser to take it.