Connect it and let it fail
The three typical failures triggered rather than claimed - and one of them looks like a success.
Milestone Unknown tool, missing required field and a result of 130,152 characters stand there as real output.
The server runs. Now comes the part you should not skip: you connect it and deliberately make it fail.
Failure modes you know cost minutes. Failure modes you see for the first time in production cost an afternoon. And the third one in this chapter does not even look like a failure.
Hanging it on a harness
What does a client need for stdio? Two things: what starts the process, and where. With Claude Code and Claude Desktop that lives in a JSON file:
{
"mcpServers": {
"simhaven-helpdesk": {
"command": "node",
"args": ["--experimental-strip-types", "/pfad/zu/mcp-kurs/helpdesk/stdio.ts"]
}
}
}
For Streamable HTTP the address takes the place of the command, and the token comes along as a header. Which key applies differs from client to client. Your harness documentation governs here, not this page.
The test rig stays anyway
A connected harness says „it works“ or „it does not work“. Why, it does not say. So the client from chapter 1 stays next to it, now with a transcript:
// helpdesk/lauf.ts
const protokoll: { werkzeug: string; args: unknown; zeichen: number; ms: number }[] = [];
async function ruf(name: string, args: Record<string, unknown>) {
const start = Date.now();
const r = await client.callTool({ name, arguments: args });
const text = (r as { content: { text: string }[] }).content[0]!.text;
protokoll.push({ werkzeug: name, args, zeichen: text.length, ms: Date.now() - start });
return text;
}
Four columns, you need no more: which tool, with which arguments, how big the answer, how long it took. The third is the one you miss as soon as it is absent.
Failure 1: the tool does not exist
A model invents tool names. Not often, but regularly, and especially readily when a name suggests itself that does not exist.
await client.callTool({ name: "delete_all_tickets", arguments: {} });
{"content":[{"type":"text",
"text":"MCP error -32602: Tool delete_all_tickets not found"}],
"isError":true}
Two things to remember. First, this does not throw. The SDK returns a result
with isError: true, and the text lands at the model. Whoever waits for an
exception in their own code waits in vain.
Second, the message is worth something. It names the tool that does not exist, and a model then usually looks in its tool list instead of trying again. What helped there was not your server. It was the protocol.
Failure 2: the required field is missing
await client.callTool({ name: "answer_ticket", arguments: { key: "T-1041" } });
{"content":[{"type":"text",
"text":"MCP error -32602: Input validation error: Invalid arguments for tool
answer_ticket: Required at antwort"}],
"isError":true}
The body never started. The schema caught it, and the message names tool and field.
The same with a wrong enum value:
MCP error -32602: Input validation error: Invalid arguments for tool
categorize_ticket: Invalid enum value.
Expected 'frage' | 'stoerung' | 'rechnung' | 'recht' | 'spam', received 'dringend'
at kategorie
And for comparison the case the schema knows nothing about, a ticket that does not exist:
{"content":[{"type":"text","text":"{\"ok\":false,\"error\":\"unbekanntes Ticket: T-9999\"}"}],
"isError":true}
The two differ in who is responsible. The schema checks the shape, your body checks the inventory. What you push into the schema you need not phrase yourself, and what concerns the inventory the schema cannot know. Both messages should do the same job: say what applies, and not only what does not.
Failure 3: the result that looks like a success
Eight tickets are a teaching example. A helpdesk after a long weekend has four hundred. That is what the parameter from chapter 3 is for:
// helpdesk/gross.ts
const bestand = neuerBestand(50); // 50 Runden zu acht Tickets
// Das Werkzeug, das jeder einmal baut: alles auf einmal, Volltext inklusive.
server.registerTool(
"export_inbox",
{ description: "Gibt den kompletten Posteingang mit allen Volltexten zurück.", inputSchema: {} },
async () => ({
content: [{ type: "text", text: JSON.stringify([...bestand.values()], null, 2) }],
}),
);
Measured, not estimated:
| Call | Characters |
|---|---|
tools/list (the tool list itself) | 4,206 |
export_inbox | 130,152 |
list_tickets without a cap | 81,970 |
list_tickets with limit: 25 | 5,223 |
helpdesk_stats | 115 |
get_ticket | 365 |
export_inbox returns 130,152 characters, roughly 33,000 tokens. It answers with
ok, it takes milliseconds, and no error appears in the log. What is spent
anyway is the agent's context, for the rest of the session.
The second row is the more uncomfortable one. list_tickets without full
texts, only subject lines and status, still comes to 81,970 characters at four
hundred tickets. So leaving out the full text is not enough. A list needs a cap,
otherwise it grows with the inventory.
The cap from chapter 3 turns that into 5,223 characters, one sixteenth. It costs four lines:
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional(),
const seite = treffer.slice(offset, offset + limit);
rest: Math.max(0, treffer.length - offset - seite.length),
The maximum belongs in the schema and not in the description. A max(100)
gets enforced, whereas a „please do not request more than 100“ in the description
stays a request that a model under pressure will skip.
And helpdesk_stats at 115 characters shows it the other way round. A tool that
answers a question instead of shipping data costs three orders of magnitude less.
Where a number is enough, no list should come back.
What belongs in the transcript
After the run the log sits there, and three columns say enough:
- Characters per call. Anything above ten thousand needs an explanation.
- Calls per tool. The same tool three times in a row with the same arguments means you did not describe what is missing.
- Error rate per tool. A tool rejected every second time has no model problem. It has a schema problem.
The component Benchmarks measures this kind of thing at scale. For a single server the small version is enough: four columns in an array.