Test a tool-using refund agent and its MCP server the way agents actually fail: tool selection and parameter construction asserted separately, a trajectory spec with loop detection, and contract tests for the server's happy, error, and timeout paths. One provided trace contains a seeded right-answer-wrong-path behavior — your trajectory tests must catch what final-answer checks cannot.
Final-answer testing is how agent bugs ship. An agent that refunds the correct amount after skipping the status check it was required to make will pass every output assertion you write — until the day the skipped check mattered. Sameer's team gates their refund agent on answer correctness alone; this lab builds the bench they should have: the MCP tool layer contract-tested like any API, tool calls asserted on two independent axes (did it pick the right tool? did it build the right parameters?), and trajectories checked against an expected-path spec that flags loops, skipped verifications, and step waste even when the final answer sparkles.
~120 minutes hands-on · Advanced tier (maps to module M7 and playbooks PB-42/PB-43/PB-44). Cost: $0 (FastMCP in-memory client, pytest, provided traces; live-agent extension optional).
mkdir -p qa-lab-14 && cd qa-lab-14
python3 -m venv .venv && source .venv/bin/activate
pip install fastmcp pytest pytest-asyncio
server.py — the MCP server under test:
import asyncio
from fastmcp import FastMCP
mcp = FastMCP("orders")
ORDERS = {
"ORD-100": {"status": "delivered", "total": 450.0, "email": "a@x.in"},
"ORD-101": {"status": "in_transit", "total": 1200.0, "email": "b@x.in"},
"ORD-102": {"status": "delivered", "total": 89.0, "email": "a@x.in"},
}
@mcp.tool
def get_order(order_id: str) -> dict:
"""Fetch one order's status, total and customer email."""
if order_id not in ORDERS:
raise ValueError(f"unknown order: {order_id}")
return {"order_id": order_id, **ORDERS[order_id]}
@mcp.tool
def search_orders(email: str) -> list[dict]:
"""List all orders for a customer email."""
return [{"order_id": k, **v} for k, v in ORDERS.items()
if v["email"] == email]
@mcp.tool
async def refund_order(order_id: str, amount: float) -> dict:
"""Issue a refund. Policy: only delivered orders; amount <= total."""
await asyncio.sleep(0) # placeholder for gateway latency (step 6)
o = ORDERS.get(order_id)
if o is None:
raise ValueError(f"unknown order: {order_id}")
if amount > o["total"]:
raise ValueError("amount exceeds order total")
return {"refunded": amount, "order_id": order_id}
if __name__ == "__main__":
mcp.run()
traces/ — three recorded agent trajectories for the same task class ("Customer a@x.in wants a full refund for their delivered atta order, ORD-100"). Save each as JSON:
{"trace":"good","final_answer":"Refunded Rs 450 for ORD-100.",
"steps":[
{"tool":"get_order","args":{"order_id":"ORD-100"}},
{"tool":"refund_order","args":{"order_id":"ORD-100","amount":450.0}}]}
{"trace":"wrong_path","final_answer":"Refunded Rs 450 for ORD-100.",
"steps":[
{"tool":"search_orders","args":{"email":"a@x.in"}},
{"tool":"search_orders","args":{"email":"a@x.in"}},
{"tool":"refund_order","args":{"order_id":"ORD-100","amount":450.0}},
{"tool":"get_order","args":{"order_id":"ORD-100"}}]}
{"trace":"wrong_params","final_answer":"Refunded Rs 1200 for ORD-101.",
"steps":[
{"tool":"get_order","args":{"order_id":"ORD-101"}},
{"tool":"refund_order","args":{"order_id":"ORD-101","amount":1200.0}}]}
Note what the traces already teach: wrong_path produces the identical final answer to good.
test_mcp_contract.py, use FastMCP's in-memory client (async with Client(mcp) as client: after importing mcp from server). Assert: list_tools() returns exactly the three expected tools; each tool's input schema declares the required parameters with correct types (order_id: string, amount: number). Schema assertions are your version-drift tripwire — a renamed parameter breaks agents silently, and this test loudly.get_order("ORD-100") returns status/total/email; get_order("ORD-999") raises a tool error whose message names the unknown id (agents recover from informative errors — assert the message content, not just the raising); refund_order over-amount raises; search_orders for a@x.in returns exactly two orders. Run pytest -q — the server as provided should pass all of these.REFUND_DELAY from an env var in server.py, default 0), set it to 10 in the test, call refund_order with a 5s client timeout, and assert you get a timeout error and that a follow-up get_order still works (the server survives; no wedged state). As provided, the server has no internal timeout handling — decide and document in bench-notes.md whether the contract belongs server-side (tool deadline) or client-side (agent must set timeouts), then implement your choice so the test passes. There is no free lunch here; the point is that somebody must own the timeout, explicitly.test_toolcalls.py, write two independent check functions over a trace: assert_tool_sequence(trace, expected) — the ordered list of tool names matches an allowed pattern; and assert_param_construction(trace) — every refund_order call has amount exactly equal to the total returned by a priorget_order for the same order_id in the same trace. Run both over all three traces and record the 2×3 result grid in bench-notes.md. They fail differently, which is the whole reason to separate them: wrong_params passes selection and fails parameters; wrong_path fails selection-order and passes parameter arithmetic.test_trajectory.py, encode the expected-path spec for the refund task: (a) get_order MUST precede any refund_order for the same id — the status verification is required, not decorative (policy says delivered-only; an agent that refunds before checking would refund in_transit orders whenever the answer happens to be right); (b) no identical tool+args call may repeat (loop detection); (c) step budget ≤ 3 for this task class; (d) no tool calls after the goal is achieved (post-goal calls are wasted spend and a confusion signal). Run against the three traces: good passes all four; wrong_path must fail (a) precedence, (b) the repeated search_orders, (c) the budget, and (d) the post-refund get_order — four distinct trajectory defects, one perfect final answer.bench-notes.md, show the comparison your bench exists for: a final-answer-only test (assert "450" in final_answer and "ORD-100" in final_answer) — write it, run it over good and wrong_path, and record that it passes both. Then the trajectory suite's verdicts on the same pair. This before/after table is the artifact reviewers will actually read.wrong_params precisely. The trace refunded ₹1200 for an in_transit order — the parameter arithmetic is fine (amount equals total) but the precondition is violated. Decide where this check belongs and implement both layers: the trajectory spec should flag it (status from the prior get_order was not delivered), and — defense in depth — refund_order itself should enforce delivered-only server-side (as provided, it does not; fix server.py and add the contract test). Agents are probabilistic; tool-layer guardrails are not. Both layers, always.bench-notes.md: the contract matrix (discovery/schema/happy/error/timeout — pass counts), the 2×3 selection-vs-params grid, the trajectory verdicts, the false-positive demonstration, and one paragraph on extension to live agents: run your real agent N times per task (pass^k, not pass@1 — a refund agent that is right 4 of 5 times is a 20%-wrong refund machine), record traces in this same JSON shape (Langfuse/OTel exporters do this), and feed them through this exact bench unchanged."The answer was right" is the most dangerous sentence in agent QA. The wrong_path trace refunds the correct amount to the correct order — and skips the status check, loops on search, and burns double the steps. At scale, that agent refunds undelivered orders, melts your rate limits, and passes every output test you wrote.
wrong_path fails on all four, good passes.refund_order now enforces delivered-only server-side, with a contract test proving it.bench-notes.md includes the pass^k live-agent extension paragraph.Self-grading rubric (pass = 8/12):
| Dimension | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| MCP contract coverage | Happy only | + errors | + schema/discovery | + timeout with ownership decision |
| Two-axis tool asserts | Merged into one | Separate, one axis weak | Both axes, grid recorded | Both + failure-mode difference explained |
| Trajectory catch | Missed wrong_path | Caught 1–2 defects | Caught 3 | All 4 defects + false-positive demo |
| Guardrail depth | Trajectory only | Server fix only | Both layers | Both + why both, in writing |
Agents fail on axes final answers can't see — wrong tool, wrong parameters, wrong path, wasted steps — so the bench asserts each axis separately: contract tests pin the tool layer, two-dimensional call checks split selection from construction, and trajectory specs with loop detection catch the right answer reached wrongly. Then guardrails go in the server anyway, because probabilistic actors deserve deterministic walls.