What "correct" means when the caller is a model
- You are testing two contracts, not one. The protocol contract — does the server speak MCP properly, validate what it claims to validate, return the errors it advertises. And the model-facing contract — does an agent doing a realistic task make the right calls in the right order, and stop when it should.
- The tool description is a prompt. It is the text a model reads when deciding whether to call your tool. Change it and you have changed behaviour, whether or not any implementation moved — so it belongs in the fingerprint you snapshot, alongside the schema.
- An error is an instruction. A structured error with a stable code and a next step teaches an agent to recover. A stack trace or a polite paragraph of prose teaches it to retry until the context window gives out.
- Four layers, each with a blind spot the next one covers: unit tests, schema and contract validation, golden-scenario trajectory tests, and conformance suites. That layering is the shape the practice has settled into by 2026.
- Fixtures should be generated, not harvested. Schema-aware synthetic data, produced from the JSON Schema your server already publishes, beats a sample of real customer payloads on compliance and usually on coverage too.
A server usually gets merged the moment somebody watched it work once: two tools, one client, one query, a sensible-looking answer in a chat window. That demo proves the process starts, the transport connects and the handler runs. It proves nothing about the fiftieth call of a long agent run.
The thing on the other end of your API is not reading your documentation. It is reading your tool descriptions, at temperature, mid-plan, with a dozen other tools competing for the same call. A schema a human finds readable can still be one a model consistently misuses. And a technically valid error can still derail a trajectory: the agent tries again, varies one argument, tries again, and forty thousand tokens later apologises to the user.
Most teams test neither contract. If you have not built the server yet, start with the primitives-and-security guide or the twelve-step FastMCP walkthrough; if yours already faces real callers over HTTP, the transport hardening guide covers the layer underneath. This article assumes the server exists and asks how you will know it still works after the next commit.
The four layers, and what each one misses
These layers are not alternatives, and picking one is the common mistake. Each is cheap at catching a class of fault the others catch expensively or not at all. The discipline worth building is knowing which layer owns which failure, so that when production breaks you can name the test that should have caught it.
| Layer | What it catches | What it cannot catch | Where it runs |
|---|---|---|---|
| 1. Unit | Handler logic: the wrong record returned, a pagination off-by-one, a clamp that clamps the wrong way. | Anything about how the tool is described, discovered or chosen. It passes happily while the tool is invisible to every agent. | Every commit, milliseconds, no server process needed. |
| 2. Schema and contract | Renamed or retyped parameters, changed defaults, altered bounds, reworded descriptions, error types that quietly disappeared, tools added or dropped. | Whether the contract is a good one. A stable schema can still be a confusing one; nothing here tells you a model misreads it. | Every commit; needs the advertised catalogue, not a live model. |
| 3. Golden trajectory | The agent calling tools in the wrong order, skipping a required lookup, retrying in a loop, taking an irreversible action twice. | Novel situations. A golden set covers only the scenarios you wrote down. | Every commit with a mocked transport; nightly or pre-release against a live model. |
| 4. Conformance | Protocol-level deviation: malformed responses, missing capability negotiation, wrong behaviour on methods you never call but a strict client will. | Anything domain-specific. A fully conformant server can still return the wrong customer's invoice. | Pre-merge on main, or nightly; needs a running server on a port. |
Read that table as a diagnostic as much as a plan. Two patterns dominate: teams with layer one and nothing else, holding a healthy coverage number and no idea that a docstring tidy-up changed which tool the agent reaches for; and teams who jumped to layer three, running a model-in-the-loop suite that fails intermittently for reasons nobody can attribute. If you already keep evals in CI, layer two is what makes them interpretable.
Layers two and three hold the MCP-specific work, and they are the two most teams skip. The rest of this guide builds both, in standard-library Python you can lift into a repository without adopting a framework.
Schema contract tests and the golden fingerprint
Your server advertises a catalogue of tools. Each entry carries a name, a description, an input schema and — if you designed it properly — a declared set of error types. That catalogue is the contract. Every agent reads it, and none of them read your changelog.
Consider what a rename costs. An engineer at a services firm in Chennai tidies a parameter from customer_id to customerId to match a client's house style. The handler still works, the unit tests still pass, the type checker is delighted. But every agent that had learned to call that tool now sends a key the server does not recognise — silently ignored if the schema is open, rejected uninterpretably if it is not. Multiply by twelve client tenants.
The fix is a snapshot test: reduce the catalogue to a normalised, stably sorted fingerprint, commit it, and fail the build with a readable diff whenever it moves. Nothing exotic — json, hashlib and difflib do all of it.
# tests/test_tool_contract.py — schema contract snapshot for an MCP tool catalogue.
# Standard library only. Run under pytest, or: python -m tests.test_tool_contract
import difflib
import hashlib
import json
import sys
from pathlib import Path
GOLDEN = Path(__file__).parent / "golden" / "tool_contract.json"
def load_catalog():
"""In a real run, fetch this from your server's tools/list response.
Keep the shape: name, description, inputSchema, and the errors the tool declares."""
return [
{
"name": "search_orders",
"description": (
"Find orders for one customer by email address. Returns order ids, "
"amounts and status. Use get_order to read the full record."
),
"inputSchema": {
"type": "object",
"properties": {
"customer_email": {"type": "string", "minLength": 3, "maxLength": 254},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
"status": {"type": "string", "enum": ["open", "shipped", "refunded"]},
},
"required": ["customer_email"],
"additionalProperties": False,
},
"errors": ["INVALID_EMAIL", "STORE_UNAVAILABLE"],
},
]
def canonical(tool):
"""A stable, order-independent view of the parts agents actually depend on."""
schema = tool.get("inputSchema") or {}
props = schema.get("properties") or {}
required = schema.get("required") or []
fields = []
for pname in sorted(props):
p = props[pname]
fields.append({
"name": pname,
"type": p.get("type"),
"enum": sorted(p["enum"], key=repr) if isinstance(p.get("enum"), list) else None,
"default": p.get("default"),
"minimum": p.get("minimum"),
"maximum": p.get("maximum"),
"minLength": p.get("minLength"),
"maxLength": p.get("maxLength"),
"required": pname in required,
})
return {
"name": tool["name"],
# The description IS the prompt. Whitespace is noise; wording is contract.
"description": " ".join((tool.get("description") or "").split()),
"closed": schema.get("additionalProperties") is False,
"fields": fields,
"errors": sorted(tool.get("errors") or []),
}
def fingerprint(entry):
blob = json.dumps(entry, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
def snapshot(catalog):
entries = sorted((canonical(t) for t in catalog), key=lambda e: e["name"])
return {"version": 1,
"tools": [dict(e, fingerprint=fingerprint(e)) for e in entries]}
def render(snap):
return json.dumps(snap, indent=2, sort_keys=True, ensure_ascii=False).splitlines()
def main():
current = snapshot(load_catalog())
if "--update" in sys.argv:
GOLDEN.parent.mkdir(parents=True, exist_ok=True)
GOLDEN.write_text("\n".join(render(current)) + "\n")
print("wrote " + str(GOLDEN))
return 0
if not GOLDEN.exists():
print("no golden file at " + str(GOLDEN) + "; run with --update to create it")
return 1
expected = json.loads(GOLDEN.read_text())
if expected == current:
print("contract OK — nothing changed in "
+ str(len(current["tools"])) + " tool(s)")
return 0
print("\n".join(difflib.unified_diff(
render(expected), render(current),
fromfile="golden/tool_contract.json", tofile="current catalogue", lineterm="")))
was = {t["name"]: t["fingerprint"] for t in expected["tools"]}
now = {t["name"]: t["fingerprint"] for t in current["tools"]}
moved = sorted(n for n in now if was.get(n) != now[n])
gone = sorted(set(was) - set(now))
print("\nchanged: " + (", ".join(moved) or "none"))
print("removed: " + (", ".join(gone) or "none"))
print("If this change is intended, run with --update and explain it in the PR.")
return 1
if __name__ == "__main__":
raise SystemExit(main())
Four decisions in there are worth stealing. Descriptions are whitespace-normalised before hashing, so reflowing a docstring passes but rewording it fails — exactly the sensitivity you want, because the wording is the prompt. Everything is sorted, so iteration order never produces a phantom failure. Each tool carries its own fingerprint, so the failure names the tool that moved. And errors is fingerprinted too, because an agent that learned to handle STORE_UNAVAILABLE by waiting will do something else once that code disappears.
One honest limitation: canonical flattens one level of properties. If your tools take nested objects or arrays of objects, recurse into properties and items before relying on it — otherwise a change three levels down passes unnoticed. Keep the golden file readable, because a fingerprint nobody can read is one people regenerate rather than review.
Treat a golden-file diff exactly like a database migration in code review. The failing test is not the problem; it is the notification. The reviewer's job is to answer one question — "which callers assumed the old shape, and what happens to them?" — before anyone runs --update. Make regeneration a reflex and the snapshot records history instead of protecting anything.
Invalid inputs, and the errors that teach an agent to recover
The dimensions worth exercising on a tool contract are schemas, default values, invalid inputs and error types. The snapshot above covers the first two. The last two need something that actually calls the server, and that is where the model-facing consequences get sharp.
When a human sends a malformed request, they read the 400 and fix their code. When an agent sends one, it reads your error, forms a hypothesis and tries again. What you return is not a report — it is an instruction, and it will be followed. Three properties separate a useful error from a harmful one: a stable code the agent can match without parsing English; a message saying what to do differently; and nothing internal, because everything you return becomes model context.
Generating the cases from the schema you already publish
Hand-written negative tests rot. Somebody adds a parameter and does not add its four cases, and the gap stays invisible until production. Your input schema is machine-readable, so generate from it: per parameter, the missing case, the wrong-type case, the out-of-range case, plus one extra unexpected field across the call.
# tests/test_invalid_inputs.py — property-style negative cases from the tool's own schema.
import json
WRONG_TYPE = {
"string": 12345,
"integer": "not-a-number",
"number": "not-a-number",
"boolean": "yes",
"array": {"nope": True},
"object": ["nope"],
}
def valid_example(prop):
"""A minimal, in-range value for one declared parameter."""
if isinstance(prop.get("enum"), list) and prop["enum"]:
return prop["enum"][0]
t = prop.get("type")
if t == "string":
return "a" * max(1, prop.get("minLength", 1))
if t == "integer":
return prop.get("minimum", 1)
if t == "number":
return float(prop.get("minimum", 1))
if t == "boolean":
return True
if t == "array":
return []
if t == "object":
return {}
return None
def out_of_range(prop):
"""Right type, declared bound violated. None when the parameter is unbounded."""
t = prop.get("type")
if t == "string" and "maxLength" in prop:
return "x" * (prop["maxLength"] + 1)
if t in ("integer", "number") and "maximum" in prop:
return prop["maximum"] + 1
if t in ("integer", "number") and "minimum" in prop:
return prop["minimum"] - 1
if isinstance(prop.get("enum"), list):
return "__not_in_enum__"
return None
def negative_cases(tool):
"""Yield (case_id, arguments) pairs for one tool's schema."""
schema = tool["inputSchema"]
props = schema.get("properties", {})
required = schema.get("required", [])
baseline = {name: valid_example(p) for name, p in props.items()}
for name in sorted(props):
prop = props[name]
if name in required:
args = dict(baseline)
args.pop(name)
yield (tool["name"] + "::missing::" + name, args)
wrong = WRONG_TYPE.get(prop.get("type"))
if wrong is not None:
yield (tool["name"] + "::wrong_type::" + name, dict(baseline, **{name: wrong}))
bad = out_of_range(prop)
if bad is not None:
yield (tool["name"] + "::out_of_range::" + name, dict(baseline, **{name: bad}))
yield (tool["name"] + "::extra_field", dict(baseline, __unexpected__="surprise"))
# ---- the seam: swap this for your real transport ---------------------------
def call_tool(name, arguments):
"""Normalise your stdio / streamable-HTTP client into:
{"ok": bool, "error_code": str or None, "message": str or None, "content": any}"""
raise NotImplementedError
def check(tool):
"""Returns (failures, warnings). Anything in failures should fail the build."""
strict = tool["inputSchema"].get("additionalProperties") is False
failures, warnings = [], []
for case_id, args in negative_cases(tool):
# An unknown key is only a contract violation if you declared the schema closed.
bucket = failures if strict or not case_id.endswith("::extra_field") else warnings
try:
resp = call_tool(tool["name"], args)
except Exception as exc: # an unhandled exception is always a failure
failures.append(case_id + ": unhandled " + type(exc).__name__ + ": " + str(exc))
continue
if resp.get("ok"):
preview = json.dumps(resp.get("content"), default=str)[:120]
bucket.append(case_id + ": accepted invalid input, returned " + preview)
elif not resp.get("error_code"):
bucket.append(case_id + ": refused with prose, no error code: "
+ repr(resp.get("message")))
return failures, warnings
The call_tool seam is the only part you replace. Point it at a real client over stdio for a local server, or at your streamable-HTTP endpoint for a hosted one, and normalise the response into that four-key dictionary. Everything upstream stays identical, laptop or staging.
Note how the extra-field case is graded. If your schema sets additionalProperties: false, an unknown key must be rejected and silent acceptance is a build failure. If it does not, the test downgrades to a warning that tells you what your server actually does with unknown keys — nearly always "ignores them", which is precisely how a renamed parameter becomes a call that succeeds while doing the wrong thing.
Return errors an agent can act on: a stable code such as INVALID_EMAIL, and a one-line message naming the offending parameter and the constraint it violated. "customer_email must be a valid address; received an integer" tells the agent which argument to change and how, so the retry differs from the original call.
Returning a stack trace, a raw database error, or a courteous paragraph with no code. All three cost you twice. The agent cannot tell a permanent failure from a transient one, so it retries the identical call; and the internals you echoed — a table name, a path, a connection string fragment — now sit in a transcript. Many of the 143,000 findings across public MCP servers started as an unscrubbed error message.
Golden trajectory tests over a mocked transport
Layers one and two verify the server in isolation. Layer three asks the question that matters to whoever pays for it: put an agent in front, give it a realistic task, and does the right sequence happen?
The temptation is to run a live model against a live server and assert on the final answer. Resist it as the default: that test is slow, costs money on every commit, fails for reasons unrelated to your change, and asserts on the one thing you should never assert on — the model's prose. Start with a mocked transport and a scripted sequence of calls: deterministic, free, milliseconds, and it tests what a golden trajectory is for.
# tests/test_trajectory.py — golden trajectory over a mocked MCP transport.
import json
class MockTransport:
"""Records every tool call and serves canned results. No network, no model."""
def __init__(self, results):
self.results = results # {tool_name: [result, result, ...]}
self.calls = [] # [(name, arguments), ...]
self.state = {"refunds_issued": [], "emails_sent": []}
def call_tool(self, name, arguments):
self.calls.append((name, dict(arguments)))
queue = self.results.get(name)
if not queue:
# Empty queue means the agent called this more often than the scenario allows.
return {"ok": False, "error_code": "UNSCRIPTED_CALL", "message": name}
result = queue.pop(0)
if result.get("ok") and name == "issue_refund":
self.state["refunds_issued"].append(arguments["order_id"])
if result.get("ok") and name == "send_email":
self.state["emails_sent"].append(arguments["to"])
return result
# Fields the contract does NOT fix — an agent may legitimately vary or omit these.
OPTIONAL = {
"search_orders": {"limit", "status"},
"issue_refund": {"reason"},
"send_email": {"subject", "body"},
}
def normalize(name, arguments):
"""Compare on what the contract fixes, not on what the model is free to choose."""
ignored = OPTIONAL.get(name, set())
out = {}
for key, value in arguments.items():
if key in ignored:
continue
out[key] = value.strip().lower() if isinstance(value, str) else value
return out
def assert_trajectory(expected, actual):
"""expected / actual: [(tool_name, arguments)]. Raises with a readable report."""
problems = []
for i in range(max(len(expected), len(actual))):
want = expected[i] if i < len(expected) else None
got = actual[i] if i < len(actual) else None
if want is None:
problems.append("step " + str(i) + ": unexpected extra call " + got[0])
continue
if got is None:
problems.append("step " + str(i) + ": missing call, expected " + want[0])
continue
if want[0] != got[0]:
problems.append("step " + str(i) + ": expected " + want[0] + ", got " + got[0])
continue
w = normalize(want[0], want[1])
g = normalize(got[0], got[1])
if w != g:
problems.append(
"step " + str(i) + ": " + want[0] + " arguments differ"
+ "\n expected " + json.dumps(w, sort_keys=True, default=str)
+ "\n actual " + json.dumps(g, sort_keys=True, default=str))
if problems:
raise AssertionError("trajectory mismatch:\n" + "\n".join(problems))
def run_scripted_agent(transport, script):
"""Stand-in for the model: replays a fixed sequence, stopping on the first error."""
for name, arguments in script:
if not transport.call_tool(name, arguments).get("ok"):
break
return transport
def test_refund_happy_path():
transport = MockTransport({
"search_orders": [{"ok": True, "content": [{"order_id": "IN-4417", "amount": 2499}]}],
"issue_refund": [{"ok": True, "content": {"refund_id": "rf_88"}}],
"send_email": [{"ok": True, "content": {"queued": True}}],
})
script = [
("search_orders", {"customer_email": "asha@example.in", "limit": 5}),
("issue_refund", {"order_id": "IN-4417", "amount": 2499, "reason": "damaged in transit"}),
("send_email", {"to": "asha@example.in", "subject": "Your refund", "body": "..."}),
]
run_scripted_agent(transport, script)
assert_trajectory(
expected=[
("search_orders", {"customer_email": "Asha@Example.in"}),
("issue_refund", {"order_id": "IN-4417", "amount": 2499, "reason": "any wording"}),
("send_email", {"to": "asha@example.in"}),
],
actual=transport.calls,
)
assert transport.state["refunds_issued"] == ["IN-4417"]
assert transport.state["emails_sent"] == ["asha@example.in"]
The assertion helper decides whether this suite survives contact with a real team. Compare on the tool name and a normalised view of the arguments — drop the fields you declared optional, case-fold and trim the strings. The OPTIONAL map is your variance budget, written down. An agent may pick a different limit and phrase a reason however it likes; it may not invent an order_id.
Two assertions close the test, and the second matters most. The sequence says the agent looked up the order before refunding it; the state says exactly one refund was issued, for the right order. State assertions catch what sequence comparison misses: an agent reaching the correct end state via two refunds and a cancellation.
Notice the UNSCRIPTED_CALL path. When a tool's result queue runs dry, the agent has called it more often than the scenario allows — the retry-loop signature, surfaced as a clean failure rather than an IndexError. Write one scenario per behaviour you care about: happy path, not-found, ambiguous input where the agent should ask, and refusal where it should stop.
The instant you assert on the model's prose, you have a test that fails on every model upgrade and passes when the agent does something dangerous in polite language. Assert on the calls and the state. Keep a small live-model suite for what a mock cannot answer, and run it nightly. Our guides on trajectory and outcome evaluation and evals agents cannot game go deeper on that half.
Every article here is written by a Verified Builder. Want your name on the next one?
AI Tech Connect lists AI engineers, founders and researchers across India and the UK — and the people hiring browse it to find them. Adding your profile is free.
Become a Verified Builder →Fixtures that do not leak
Every layer above needs data, and the path of least resistance is to copy real payloads out of production, change the names and commit them. Do not. Prefer schema-aware synthetic data generated from the JSON Schema your server already exposes: better for compliance, and it keeps personal data out of fixture files entirely.
The compliance argument is the same on both sides of the market. An Indian services firm running one MCP server per client tenant cannot put tenant A's order records into a repository tenant B's engineers review. A UK health or financial services team has the worse version, because a fixture holding real personal data is a disclosure somebody has to explain. Fixtures travel — into CI logs, failure artefacts and forks.
The quality argument persuades engineers faster. Production samples come from the distribution your system already survived: the emails that parsed, the amounts in range, the identifiers that existed. Generating from the schema lets you ask for what a real dataset happens not to contain — the maximum-length string, the boundary integer, the Unicode name that breaks a downstream slug. It is the same generator as the invalid-input cases, with the constraints honoured instead of violated.
Two habits make it stick. Seed the generator, so a failing case is reproducible from the seed in the log rather than from a fixture somebody has regenerated. And where a tool touches something irreversible — a payment, a customer message, a deletion — make the mock, not a configuration flag, the thing standing between your suite and the real world. A flag can be set wrongly.
Anonymisation is not generation. Swapping names and truncating card numbers leaves the structure, timing, amounts and correlations intact — often enough to re-identify a record — and leaves you holding a file whose provenance you must defend. Generate from the schema and the question never arises. If you must start from production shapes, extract the schema and throw the payloads away.
Wiring it into CI: what blocks a merge and what only warns
The MCP Inspector is the official visual testing tool for MCP servers, maintained at github.com/modelcontextprotocol/inspector and documented at modelcontextprotocol.io. It is browser-based, connects over stdio, SSE or streamable HTTP, and has two modes: an interactive one for exploring a server by hand, and a CLI mode that belongs in CI, run against your tool catalogue so schema regressions are caught before a model sees them.
Conformance is the fourth layer, and the pattern is the same wherever your suite comes from: a CI action starts the server on a port, runs the checks against it, and signals the result through the process exit code. That plainness is what lets all four layers run from one script, identically on a laptop and on a runner.
#!/usr/bin/env bash
# ci/mcp-contract-gate.sh — ILLUSTRATIVE ordering. Check every external command
# against the docs for your protocol revision before adopting it.
set -uo pipefail
FAIL=0
echo "== layer 1: unit =="
python -m pytest tests/unit -q || FAIL=1
echo "== layer 2: schema contract snapshot =="
python -m tests.test_tool_contract || FAIL=1
echo "== layer 2b: invalid inputs generated from the schema =="
python -m pytest tests/test_invalid_inputs.py -q || FAIL=1
echo "== layer 3: golden trajectories, mocked transport =="
python -m pytest tests/test_trajectory.py -q || FAIL=1
echo "== layer 4: conformance against a running server =="
./scripts/start-server.sh &
SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null' EXIT
sleep 2
# The MCP Inspector CLI run goes here, plus any conformance suite you adopt.
# Both signal pass/fail through the exit code, which is all this script needs.
if ! ./scripts/run-conformance.sh "http://127.0.0.1:8931"; then
if [ "${BRANCH:-}" = "main" ]; then
FAIL=1
else
echo " WARN conformance failed — advisory on this branch"
fi
fi
exit "$FAIL"
That script is deliberately dull, and the external commands are placeholders on purpose. The Inspector's flag surface and any conformance runner's invocation change between releases; verify both against the documentation for the revision you target. As of the 2026-07-28 revision, the Inspector documentation sits under docs/2026-07-28/tools/inspector on the protocol site.
Choosing the gate
What blocks a merge should be exactly what is deterministic. Unit tests, the contract snapshot and the schema-driven invalid-input cases all qualify: no model, no sampling, so a failure means something concrete changed and the diff shows what. Golden trajectory tests over a mocked transport are deterministic too, and belong in the blocking set.
What warns should be what is legitimately noisy. Conformance runs that depend on a server starting cleanly, and anything with a live model in the loop, are better advisory on a pull request and blocking on main. A gate that fails for environmental reasons twice a week is one people learn to re-run rather than read. As the mutation testing guide puts it, a threshold nobody believes in becomes theatre.
Pin the protocol revision your conformance run targets, and record it next to the golden file. The specification has a 2026-07-28 revision that maintains interoperability with earlier revisions in supported scenarios — exactly where silent version drift leaves you with a suite that is green about the wrong thing. If your server has not moved yet, do the migration first and re-baseline afterwards.
What this still will not catch
Four layers of green tests buy you a great deal and one specific illusion. Everything above validates a contract you wrote against scenarios you imagined. Production supplies neither: real agents combine your tools with tools you have never seen, in sessions longer than any fixture.
The habit that closes the loop is reading failures backwards: start from what the agent visibly did wrong, work back to the likely server-side cause, then name the layer that should have caught it. That last step turns an incident into a permanent test rather than a fix.
| Symptom in agent behaviour | Likely server-side cause | Layer that should have caught it |
|---|---|---|
| Agent repeats a tool call with near-identical arguments, then gives up | Error without a stable code, or a message that never names the parameter to change | Layer 2 — invalid-input cases assert on error shape, not just on refusal |
| Agent stops using a tool it had been calling reliably | A reworded description, renamed parameter or tightened bound shipped without review | Layer 2 — the contract snapshot, if descriptions are fingerprinted |
| Agent picks the wrong tool between two similar ones | Overlapping descriptions, or too many tools for reliable selection | Layer 3 — a golden scenario that distinguishes the pair; see tool retrieval if the catalogue is large |
| Irreversible action taken twice | No idempotency key, or a timeout returning an error after the write committed | Layer 3 — final-state assertions, not sequence assertions alone |
| Agent acts on data it should not have reached | Tenancy enforced in the handler but not in the schema or the auth check | Layer 1 plus authorisation tests; the contract layer cannot see tenancy |
| Works with one client, fails with another | Protocol deviation on a method you never exercise yourself | Layer 4 — conformance against the revision you claim to support |
| Internal detail appears in a user-visible transcript | Unscrubbed exception text returned as tool output | Layer 2 — assert that no error body matches your internal identifier patterns |
What the table cannot give you is the first column for free. That comes from instrumentation: a trace per session, a span per tool call, redacted arguments, the error code, the duration, and a correlation identifier tying a user request to every call it produced. Without it, "the agent behaved oddly" is a report you cannot act on. Our guide to instrumenting agents with OpenTelemetry covers the mechanics.
The other blind spot is everything on the far side of your process boundary. If your agent also loads third-party MCP servers or community skills, your suite says nothing about theirs, and their tool descriptions sit in the same context window as yours, competing for the same calls. That is a supply-chain question, answered before installation rather than in CI.
None of this is exotic. It is contract testing, property-based negative cases, golden files and conformance suites — all older than the protocol — pointed at a consumer that reads prose, forms hypotheses and acts on them. The unusual part is that the description field is now executable, so it deserves a fingerprint and a code review. Get the four layers running unattended and your server stops being a demo somebody watched work once.