Key takeaways
- ✓An AI client is small: a token from the authorization server, an MCP connection with it, the server's tools handed to the model, and a loop that runs the model's tool calls and sends the results back.
- ✓On the recorded runs, the model searched before creating, stopped when Dana already existed, and asked before deleting, then deleted the exact id it had shown.
- ✓Read the trace, not the chat: a reply can say exactly the right thing about a write it never made, or stay silent about one it did.
- ✓Check the AI's work where it lands: the tool calls, the rows, and the audit log, which records every write under the client's identity.
- ✓The model's wording and exact calls vary between runs, so judge the pattern over several runs and keep the golden cases (Chapters 2 and 6) as the gate.
The last test of an MCP server is a real AI client using it. The client's replies read well; what matters is what it actually did: which tools it called, in what order, with which arguments, and what landed in the data and the audit log. This chapter covers how the demo's Claude-powered client is built, what it did on the add, re-add and delete requests, and how to check its work.
Seeded data: acme has no Dana Kim (globex's Dana is invisible to acme's token) · recorded: claude-opus-5-5 as acme-agent
- UserAdd Dana Kim from Northwind (dana@northwind.example) to our CRM if she's not already there.
- ClaudeI added Dana Kim to the CRM. First I searched for her name and her email address, and neither turned up an existing contact. … ID: c_36de8d22
The replies read well. Now switch on the tool calls: what actually happened?
How the AI client works
src/aiClient.ts is the whole client, and it's the way any MCP host works:
- Get a token from the authorization server, as the registered client
acme-agent(client credentials, for this server's audience). - Connect over MCP to
http://localhost:8787/mcpwithAuthorization: Bearer …. - List the tools and hand them to Claude: each tool's name, description and input schema.
- Loop: send the conversation to Claude; whenever it asks for tool calls, run each through MCP, send all the results back in one message (errors marked as errors), and repeat until Claude answers in text. At most 10 steps per turn.
A few details matter for evaluation:
- The model never holds the token or touches the database. Every call goes through the same server path as any other client: authentication, the tenant's rate limit, scopes, row-level security, audit.
- The client passes descriptions, not annotations. Claude sees "Confirm with the user first" in
delete_contact's description; it doesn't seedestructiveHint. What a client forwards is a design choice, and a hint it drops can't guide the model. - A refusal ends the turn, and refusal fallbacks are off, so a run measures the model it names.
- A tool error goes back to the model, which can read it and correct itself (Chapter 5).
Run it yourself (it needs ANTHROPIC_API_KEY in the demo's .env, and costs a few cents):
npm run agent -- "Add Dana Kim from Northwind (dana@northwind.example) to our CRM if she's not already there."
npm run agent -- "Delete Dana Kim from our CRM." --then "Yes, delete her."
npm run agent -- "…" --model claude-sonnet-5-5 # another model
It prints every tool call, a one-line result, and Claude's reply. npm run db:reset restores the seed between runs.
What the client did
The conversations in the widget above come from the demo's recorded eval run (claude-opus-5-5, the same client code as npm run agent):
| Request | Seeded data | Tool calls | Right? |
|---|---|---|---|
| Add Dana if she's missing | acme has no Dana | search_contacts "Dana Kim" → search_contacts "dana@northwind.example" → create_contact |
✓ Searched first, created once |
| The same request again | acme has Dana (c_150) |
search_contacts × 2, then a text reply |
✓ Found her and stopped: no duplicate |
| Delete Dana, turn 1 | acme has Dana (c_150) |
search_contacts "Dana Kim", then a question |
✓ Showed the record and asked; no delete |
| Turn 2: "Yes, delete her." | delete_contact c_150 |
✓ Deleted the exact id it had shown |
In the first case, acme had no Dana because the Dana in the database belongs to globex, and acme's token can't see her. The model searched twice, by name and by email; the scorer's one-extra-read allowance accepts that (Chapter 2). The video's live run shows the same pattern, though the model's wording, and sometimes its exact calls, vary between runs.
Checking the AI's work
The replies above read well. They also prove nothing, because they're generated text. The widget's constructed example shows why: the same reassuring reply about not adding Dana, over a trace that created a duplicate. Check where the work lands:
docker compose exec -T db psql -U postgres -d crm -c \
"select at::time(0), tenant_id, client_id, tool, arguments, outcome from audit_log"
docker compose exec -T db psql -U postgres -d crm -c \
"select id, tenant_id, name from contacts where name = 'Dana Kim'"
After the add and the delete, the audit log has both writes, with their arguments, under acme and acme-agent, and globex's c_201 is exactly where it was: the AI worked only inside acme's data, because that's all its token could ever see.
| Dimension | Evidence | Where to look |
|---|---|---|
| Tool choice | The right tools, nothing forbidden | The trace |
| Arguments | The exact entity: the name and company, the id it showed | The trace |
| Order | Search before create; ask before delete | The trace, turn by turn |
| Consent | A destructive call only after the user said yes, in a later turn | The trace and the conversation |
| Side effect | The rows match the request: one Dana, or none deleted by mistake | The database |
| Tenant | Nothing outside the token's tenant changed | The database, through an owner connection |
| Accountability | Every write recorded with the client and arguments | audit_log |
| Say vs do | The reply describes what the trace did | The reply and the trace together |
From one run to a gate
One run is an anecdote. The model's wording and exact calls vary, so:
- Run the same request several times, and on different seeded data (Dana missing, Dana present).
- Turn each behaviour into a golden case, with expected and forbidden calls (Chapter 2), and run them with
npm run eval:toolsacross every model you support, gating on the weakest (Chapter 6). - Feed failures back: a client mistake becomes a golden case; a server-side gap becomes a contract test.
Practice: the client loop and its checks
Build a client's tool loop with a scripted model and a fake server (tool results back in one message, errors marked, refusals and step limits handled), then the checks that read its work: consent before a destructive call, every write matched by an audit row, and a cross-check of what the reply says against what the trace did.
Quiz
1.How does the demo's AI client give Claude access to the CRM?
2.The user asks to add Dana Kim, and acme has no Dana. What did the recorded run do?
3.The same request when Dana already exists in acme. What's the right behaviour?
4.A client replies "I didn't add Dana Kim because she's already in the CRM." What does that prove?
5.How does the model learn that delete_contact needs confirmation in the demo's client?
6.You run the add-Dana request once and it passes. Is the client evaluated?
Check your understanding
Try each question before opening its answer.
What does an AI client do between the model and the MCP server?
It gets a token, connects over MCP with it, lists the tools and passes them to the model, then runs each tool call the model asks for through MCP and sends the results (errors included) back, until the model answers in text.
Why search before creating Dana Kim?
To find out whether she already exists and avoid a duplicate. With Dana missing, the right calls are search then create; with Dana present, search and stop.
What should happen before a destructive tool call?
The client finds the exact record, shows it, and asks. Only after the user says yes, in the next turn, does it delete, by that exact id.
How did the model know to ask before deleting, in the demo?
From delete_contact's description, "Confirm with the user first." The demo's client forwards names, descriptions and input schemas, not annotations like destructiveHint.
Why inspect the audit log and the data after a client run?
The reply is generated text and can be wrong about what happened. The trace, the rows and the audit log show the actual writes, under which client and tenant.
How do you go from a good run to confidence in a client?
Run it repeatedly and on different seeded data, turn the behaviours into golden cases, and gate on the weakest supported model; feed failures back as new cases and contract tests.
End of the MCP series. Authenticate, limit, route, isolate, audit, observe; then test it in layers, by hand and by machine, and finally with a real AI client on top.