Tools: list, call, and the model in between
Our helpdesk server now has its first tool. It is called get_ticket, and it does one thing: given a ticket number, it returns that ticket's subject, status and customer. Tom, the engineer building it at a small company that sells booking software to gyms and yoga studios, types a call by hand:
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_ticket","arguments":{"ticket_id":"2318"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"terminal","version":"0.1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}Ticket 2318 comes back: Blue Heron Yoga, "Charged twice for October", open. It works.
But Tom will not be the one calling it. The model will. And people at the company write ticket numbers in chat as #2318. Nothing stops the model from passing exactly that: "ticket_id": "#2318". Suppose the server's code did not check for it, looked the ticket up, found nothing, and then read a field of the ticket that does not exist. In Node, that throws an exception. Nothing catches it, and the server process ends.
Here is the puzzle for this lesson. When the model passes "#2318", who should fix the mistake, the model or the server? And how does the model even find out that it made one? The model never sees the server. It sees only what the host tells it.
By the end of this lesson you will be able to define a tool by hand: its name, description, input schema, output schema and annotations. You will handle a call to it, and decide when a mistake should be a tool error and when a protocol error. Then you will connect the hand-written server to two real hosts and watch each one call get_ticket.
| Thing | Value |
|---|---|
| our helpdesk server | handwritten/server.ts, still no SDK, 152 lines at tag lesson-03 |
| its one tool | get_ticket, argument ticket_id, digits only |
| tickets it knows | 2318, 2319 and 2320, a stand-in for the database; lesson 5 adds the real 12,400 |
| ticket 2318 | Blue Heron Yoga, customer cus_1042, "Charged twice for October", open |
| hosts recorded | Claude Code 2.1.300 and Codex CLI 0.160.0, on 2026-10-15 |
| protocol revision | 2026-07-28 |
Every message in this lesson was recorded from that server. The code is at tag lesson-03, and the sessions are in transcripts/lesson-03/.
The Loop Around a Tool Call
Start with who does what. Lesson 1 drew the parts: the host is the AI application the person uses, a client is the part of the host that talks to one server, and the model sits inside the host and talks only to the host. Every tool call runs inside a loop that the host owns. In the amount this course needs, here it is:
1. list the client asks the server for its tools client -> server tools/list
2. give the host gives the tools to the model, in the
model API's own format, with the conversation host -> model
3. ask the model answers with a request to call a tool model -> host
4. check the host decides whether to ask the user first host -> user
5. call the client asks the server to run the tool client -> server tools/call
6. return the host gives the result to the model host -> model
7. repeat the model asks for another tool (back to 3),
or writes its answer, and the loop ends model -> hostTwo of these steps are MCP messages: 1 and 5. The other five happen inside the host, between the host and its model, or between the host and the user. The MCP specification draws the same picture for tools: discovery with tools/list, then "Tool Selection" by the model, then invocation with tools/call, then the client hands the result back for the model to process.
Step 7 is the one to remember for the puzzle. The loop does not end when a tool returns. The model reads the result and decides what to do next, which can be another call. That is where a mistake can be fixed, if the result tells the model what went wrong.
tools/list on the Wire
A server first says it has tools at all. Lesson 2 built a server/discover result with empty capabilities. In this lesson's server, one field changed:
"capabilities": {
"tools": {}
},The tools page of the specification makes this a rule: "Servers that support tools MUST declare the tools capability". An empty object means "I have tools, and nothing more to say about them".
Now the list itself. The request has no input of its own, only the _meta that every request carries:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "terminal",
"version": "0.1.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}The result:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"tools": [
{
"name": "get_ticket",
"title": "Get ticket",
"description": "Fetch one support ticket by its number, with its subject, status and customer id. Ticket numbers are digits only, for example 2318.",
"inputSchema": {
"type": "object",
"properties": {
"ticket_id": {
"type": "string",
"pattern": "^[0-9]+$",
"description": "Ticket number, digits only, e.g. 2318"
}
},
"required": [
"ticket_id"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"ticket_id": {
"type": "string"
},
"subject": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"open",
"pending",
"closed"
]
},
"customer_id": {
"type": "string"
}
},
"required": [
"ticket_id",
"subject",
"status",
"customer_id"
]
},
"annotations": {
"readOnlyHint": true,
"openWorldHint": false
}
}
],
"ttlMs": 300000,
"cacheScope": "public"
}
}The tools array has one entry. Here is each of its fields.
name:get_ticket. This is what a call names. The specification says tool names SHOULD be 1 to 128 characters, using only letters, digits,_,-and., and unique within the server. Two servers can both have aget_ticket; the host keeps them apart, for example by putting the server's name in front.title:Get ticket. A name for people, shown in the host's interface.description: what the tool does, in a sentence for the model. Ours ends with the id format and an example.inputSchema: the shape of theargumentsa call must send, written in JSON Schema. The specification uses the 2020-12 version of JSON Schema when the schema does not name one. Ours says: an object, with one propertyticket_id, a string that must match^[0-9]+$(one or more digits, nothing else), required, and no other properties allowed (additionalProperties: false).outputSchema: the shape of the structured result, described in thetools/callsection below.annotations: hints about how the tool behaves. A later section explains why they are only hints.
The two fields after the array, ttlMs and cacheScope, are the same cache hints you saw on server/discover. Work out this one:
ttlMs = 300,000 ms = 300 s = 5 minutesSo a client may reuse this list for 5 minutes without asking again. "public" means it is the same for every user. There is no nextCursor field, so this is the whole list on one page. Lesson 6 covers lists long enough to need pages.
What the Model Reads
The host does not hand this object to the model. In step 2 of the loop it rewrites each tool into its model API's own tool format. In the Claude API, a tool definition has a name, a description and an input_schema, and nothing else. Claude Code also puts the server's name in front of the tool's name, so the model sees mcp__helpdesk__get_ticket.
So the model learns three things about our tool: a name, a sentence and a schema. It never sees our code. It cannot ask a follow-up question. It does not know what ticket numbers at this company look like unless one of those three things says so. The description and the input schema are the model's only documentation.
That is why ours carries the format twice, in two forms. The description says it in words, "Ticket numbers are digits only, for example 2318", for the model to read. The schema says it as a rule, "pattern": "^[0-9]+$", for programs that check arguments. The example matters as much as the rule: a model that has seen 2318 written out is less likely to invent another form of it.
These words cost tokens. Measured with the o200k_base tokenizer (a tokenizer turns text into the numbered pieces, tokens, that a model reads and is billed by):
tool list (get_ticket only), as one JSON array 91 tokens
server instructions 12 tokens
total 103 tokens, sent again on every model callA model API keeps no memory between calls, so the host sends the tool definitions on every turn of the loop. One small tool is cheap. Lesson 4 measures what happens with a hundred.
tools/call on the Wire
Step 5 of the loop. The client sends the tool's name and the arguments the model chose:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_ticket",
"arguments": {
"ticket_id": "2318"
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "terminal",
"version": "0.1.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}params.name picks the tool. params.arguments must match the tool's inputSchema. _meta is the same block as on every request.
Before you read the result, predict one thing. The tool declared an outputSchema. Where in the result will the ticket appear?
...
In two places:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "{\"ticket_id\":\"2318\",\"subject\":\"Charged twice for October\",\"status\":\"open\",\"customer_id\":\"cus_1042\"}"
}
],
"structuredContent": {
"ticket_id": "2318",
"subject": "Charged twice for October",
"status": "open",
"customer_id": "cus_1042"
},
"isError": false
}
}contentis a list of content blocks. Each block has atype. This one istext, and its text is the ticket written as a JSON string. Other types exist:imageandaudiocarry base64 data,resource_linkpoints to a resource, andresourceembeds one. Lesson 6 uses the last two.structuredContentis the same ticket as a JSON value, not as text. Because the tool declared anoutputSchema, the specification says the server "MUST provide structured results that conform to this schema", and clients "SHOULD validate structured results against this schema". Ours has the four required fields, andstatusis one of the three allowed values.isError: falsesays the tool ran and this is its answer. The next section is about the other value.
Why send the ticket twice? Because of a sentence in the specification: "For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block." Older clients read only content. Newer clients can read structuredContent and check it against the schema.
Which One the Model Sees Is Not Up to You
Here is a limit you should know before you design any result. The specification does not say which of the two fields the host puts in front of the model. The MCP project's own roadmap, in August 2026, says so plainly: "A tools/call response can carry the same output in more than one form, and a server developer today has no way to know which form a given client will put in front of the model."
So write both as if each one were the only thing the model will read. The text block must say everything the model needs. The structured value must say the same thing, in the shape the schema promises. If they ever disagree, different hosts will show their models different facts about the same ticket.
What changed. Before revision 2026-07-28, a result was a plain object with no
resultTypefield: atools/callresult began directly withcontent. 2026-07-28 maderesultTyperequired on every result,"complete"for a finished one, so that a result can also say"input_required": "not done, I need more input first", which lesson 7 uses to confirm a refund (SEP-2322). For older servers, clients "MUST treat an absentresultTypeas"complete"." You will recognise an old server by a result with noresultType. Our own answer toinitialize, from lesson 2, is one.
When the Argument Is Wrong
Back to the puzzle. The model sends "#2318". The server can answer in three ways. Compare them.
It crashes. An exception nobody catches ends the process. The stdio specification says the client "SHOULD restart it", and that "any in-flight requests are ... lost". The call never gets a response with its id. The host knows only that the server went away. Whatever it then tells the model, it cannot say "the id format is wrong", because nothing said that. The model learns nothing it can use.
It returns a protocol error. A JSON-RPC error, for example -32602, invalid params. This at least answers the request. But the specification has a view on what hosts do with these: "Clients MAY provide protocol errors to language models, though these are less likely to result in successful recovery."
It returns a tool error. A normal result, with isError: true and a message the model can read. This is what our server does:
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "No ticket \"#2318\". Ticket numbers are digits only, for example 2318."
}
],
"isError": true
}
}The request that produced it differed from the working call in one value, "ticket_id": "#2318", and had id 4.
The specification describes exactly this case. It defines two kinds of error for tools:
- "Protocol Errors indicate issues with the request structure itself that models are less likely to be able to fix", such as an unknown tool, a request that does not fit the schema of
tools/callitself, or a server failure. They are JSON-RPC errors. - "Tool Execution Errors contain actionable feedback that language models can use to self-correct and retry with adjusted parameters", such as API failures, "Input validation errors (e.g., date in wrong format, value out of range)" and business logic errors. They are results with
isError: true.
And then the rule that decides the puzzle: "Clients SHOULD provide tool execution errors to language models to enable self-correction."
So the tool error goes into step 6 of the loop, and the model reads it in step 7. The message names the value it got and the format it wanted, with an example. The model has what it needs to call again with "2318". Whether it does is up to the model and the host. The server's job is to make the next call possible.
A Second Case: the Wrong Tool Name
Now change a different thing. The client sends a call for get_tickets, with an s, which does not exist. Everything else is correct.
The client sent this with id 5. What comes back?
...
A protocol error:
{
"jsonrpc": "2.0",
"id": 5,
"error": {
"code": -32602,
"message": "Unknown tool: get_tickets"
}
}An unknown tool is first on the specification's list of protocol errors, and this is the specification's own example, with our tool's name in it. The request itself is wrong: it names something the server never offered, and no change to an argument can fix that.
Third case, in one line: a call for a ticket that does not exist, such as 9999, goes down the same branch as #2318 and gets a tool error.
Put the rule in one table:
| What went wrong | Kind | On the wire | Who can fix it |
|---|---|---|---|
an argument the model chose ("#2318", a ticket that does not exist) | tool error | result with isError: true | the model, on its next call, if the message says how |
the request itself (unknown tool, missing _meta, not valid JSON) | protocol error | JSON-RPC error with a code | the client's code, not the model |
| the server's code throws and nobody catches it | neither: the process ends | nothing for that id | nobody, until someone reads the logs |
You might expect every failure to be a JSON-RPC error, since JSON-RPC has an error field for exactly that. That is wrong for tools. An error that the model should act on belongs in a result, because a result is what the host passes to the model. The right model: a protocol error says the request was broken; a tool error says the request was fine and the answer is "no, and here is why".
So, who should fix #2318? The model fixes the argument. The server's part is to tell it how, in words, in a result. One honest note about our hand-written message: it says "digits only" even when the id was all digits and the ticket does not exist. Our three-ticket table cannot tell the two cases apart, so the message covers both. In lesson 5, the SDK checks arguments against the input schema before our code runs, and returns a separate tool error for a value that breaks the pattern.
A mistake in an argument is a tool error the model can read; a mistake in the request is a protocol error for the client; a crash tells nobody anything.
Annotations Are Hints
Our tool carries two annotations:
"annotations": {
"readOnlyHint": true,
"openWorldHint": false
}readOnlyHint: true says the tool does not change anything. openWorldHint: false says it works inside a closed world, our own database, not the open web. The specification defines four of these hints. Their defaults matter more than they look:
| Hint | Meaning when true | Default when missing |
|---|---|---|
readOnlyHint | does not modify its environment | false |
destructiveHint | may perform destructive updates (only meaningful when not read-only) | true |
idempotentHint | calling again with the same arguments has no additional effect | false |
openWorldHint | interacts with an open world of external entities | true |
Read the defaults as a sentence. A tool with no annotations is assumed to change things, possibly destructively, possibly out in the world. So a read-only tool should say so. A host may use the hint to decide how it shows the tool, or how carefully it asks the user before a call. Lesson 7 sets destructiveHint and idempotentHint on the refund tool.
Now the limit. The schema of the specification says, above the definition of these fields: "all properties in ToolAnnotations are hints. They are not guaranteed to provide a faithful description of tool behavior", and "Clients should never make tool use decisions based on ToolAnnotations received from untrusted servers." The tools page goes further: "clients MUST consider tool annotations to be untrusted unless they come from trusted servers."
The reason is simple to state. The server writes its own annotations. A server that deletes data can call itself read-only, and nothing in the protocol checks. An annotation is a claim, not a guarantee. Lesson 10 shows what a hostile server does with that.
A List That Stays the Same
Two more properties of tools/list are easy to miss.
The order is stable. The specification says servers "SHOULD return tools in a deterministic order", because it "enables clients to reliably cache the tool list and improves LLM prompt cache hit rates when tools are included in model context." Model APIs can cache the start of a request, and the tool definitions sit at the start. If the order changes, the start changes, and the cache misses. Our list is a constant array, so its order never changes. Lesson 4 puts a price on this.
The list does not depend on the connection. The set of tools "MUST NOT vary per-connection or as a side effect of other requests on the connection." It is the statelessness rule from lesson 2, applied to tools: a server cannot unlock a tool because of something an earlier request did. The set "MAY vary by the authorization presented on the request", because credentials arrive with each request. Lesson 9 returns to this, when each request carries a token with scopes.
The Code
The server from lesson 2 grows by about 60 lines. The full file is handwritten/server.ts at lesson-03.
The Tool Definition
// The tools this server offers. The description and the input schema are
// all the model will ever know about a tool, so they say exactly what to send.
const TOOLS = [
{
name: "get_ticket",
title: "Get ticket",
description:
"Fetch one support ticket by its number, with its subject, status and customer id. Ticket numbers are digits only, for example 2318.",
inputSchema: {
type: "object",
properties: {
ticket_id: { type: "string", pattern: "^[0-9]+$", description: "Ticket number, digits only, e.g. 2318" },
},
required: ["ticket_id"],
additionalProperties: false,
},
outputSchema: {
type: "object",
properties: {
ticket_id: { type: "string" },
subject: { type: "string" },
status: { type: "string", enum: ["open", "pending", "closed"] },
customer_id: { type: "string" },
},
required: ["ticket_id", "subject", "status", "customer_id"],
},
annotations: { readOnlyHint: true, openWorldHint: false },
},
];This is the tools array from the tools/list result, written as a TypeScript constant. Every field was explained in the wire section above. The point of writing it by hand is that nothing is hidden: the object in the file is the object on the wire, byte for byte after JSON.stringify.
The Data and a Helper
// A tiny stand-in for the helpdesk database (the SDK lessons use SQLite).
const TICKETS: Record<string, object> = {
"2318": { ticket_id: "2318", subject: "Charged twice for October", status: "open", customer_id: "cus_1042" },
"2319": { ticket_id: "2319", subject: "Class schedule not syncing to calendar", status: "pending", customer_id: "cus_0877" },
"2320": { ticket_id: "2320", subject: "How do I add a second instructor?", status: "closed", customer_id: "cus_1310" },
};
function reply(id: string | number, result: object) {
send({ jsonrpc: "2.0", id, result: { resultType: "complete", ...result } });
}TICKETSholds three tickets, keyed by their number as a string. Each value already has the shape theoutputSchemapromises.replywrites a finished result. It addsresultType: "complete"to whatever result it is given, so no branch can forget it.sendis the same one-line writer from lesson 2.
Two New Methods
In handle, after the server/discover branch, two lines route the new methods:
if (method === "tools/list") return reply(id, { tools: TOOLS, ttlMs: 300000, cacheScope: "public" });
if (method === "tools/call") return callTool(id, params);tools/listreturns the constant, with the 5 minute cache hint. The_metachecks from lesson 2 have already run by the time this line is reached.tools/callhands the request tocallTool.
The server/discover branch also changed one value, capabilities: { tools: {} }, as the wire section showed. So did the initialize answer for older clients.
Calling the Tool
function callTool(id: string | number, params: any) {
// Asking for a tool that does not exist is a protocol error: the request
// itself is wrong, and the model can rarely fix that by retrying.
if (params?.name !== "get_ticket") return fail(id, -32602, `Unknown tool: ${params?.name}`);
// A bad argument is a tool error: a normal result with isError: true. The
// text goes back to the model, which can read it and call again.
const ticketId = params.arguments?.ticket_id;
const ticket = Object.hasOwn(TICKETS, ticketId) ? TICKETS[ticketId] : undefined;
if (!ticket) {
const text = `No ticket "${ticketId}". Ticket numbers are digits only, for example 2318.`;
return reply(id, { content: [{ type: "text", text }], isError: true });
}
reply(id, { content: [{ type: "text", text: JSON.stringify(ticket) }], structuredContent: ticket, isError: false });
}Line by line:
- The first
ifis the protocol error. Any name other thanget_ticketgets-32602and "Unknown tool", theget_ticketsanswer above. params.arguments?.ticket_idreads the argument. The?.means a missingargumentsgivesundefinedinstead of a crash.Object.hasOwn(TICKETS, ticketId)checks that the key is one of our own tickets. A plainTICKETS[ticketId]would also find properties that every JavaScript object inherits, such asconstructor. A model could send that as a ticket number, and the lookup would return something that is not a ticket.if (!ticket)is the tool error.#2318,9999andconstructorall land here, with a message that names the value and the expected format.- The last line is the success: the ticket as JSON text in
content, the same ticket instructuredContent, andisError: false.
This is the answer to the crash in the puzzle. The handler checks the one thing that can be missing before it uses it, so no argument the model chooses can end the process.
Seeing Which Client Is Talking
let firstLine = true;
createInterface({ input: process.stdin }).on("line", (line) => {
if (line.trim() === "") return;
if (firstLine) console.error(`helpdesk: first message from the client: ${line}`);
firstLine = false;The reading loop from lesson 2 gains two lines. The first line a client sends is written to stderr, the log channel, so it never touches the protocol.
The Model Does Not Call the Server
Connect the server to Claude Code. The command, from the repository's README:
claude mcp add helpdesk -- node "$PWD/handwritten/server.ts"helpdesk is the name Claude Code gives the server. Everything after -- is the command Claude Code runs to start it, as a child process speaking stdio. To see every line that crosses the pipe, the repository has a small tap, wire/tap.ts. It sits between the client and the server, passes each line through unchanged, and appends a copy to a log file:
claude mcp add helpdesk -- node "$PWD/wire/tap.ts" /tmp/tap.jsonl node "$PWD/handwritten/server.ts"With the tap in place, Claude Code 2.1.300 was asked about ticket 2318 on 2026-10-15. The server's log showed Claude Code's first message:
helpdesk: first message from the client: {"jsonrpc":"2.0","id":"server-discover-probe-1","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"claude-code","title":"Claude Code","version":"2.1.300","description":"Anthropic's agentic coding tool","websiteUrl":"https://claude.com/claude-code"},"io.modelcontextprotocol/clientCapabilities":{"roots":{"listChanged":true},"elicitation":{"form":{},"url":{}}}}}}It is a server/discover with the string id "server-discover-probe-1" and version 2026-07-28. That is the probe that the stdio specification recommends for a client that speaks both eras: ask in the current protocol first, and fall back to initialize only if the server does not understand. Our server understood, so Claude Code stayed on 2026-07-28. Its capabilities say it can show the user a form or a web page, which lesson 7 uses. Then came tools/list with id 0, the same list you read above, and then this:
{
"method": "tools/call",
"params": {
"name": "get_ticket",
"arguments": {
"ticket_id": "2318"
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "claude-code",
"title": "Claude Code",
"version": "2.1.300",
"description": "Anthropic's agentic coding tool",
"websiteUrl": "https://claude.com/claude-code"
},
"io.modelcontextprotocol/clientCapabilities": {
"roots": {
"listChanged": true
},
"elicitation": {
"form": {},
"url": {}
}
},
"claudecode/toolUseId": "toolu_01BXSQEWH8rdfEBc5X1BXYYi",
"progressToken": 1
}
},
"jsonrpc": "2.0",
"id": 1
}The server answered with the same ticket 2318 result you read in the tools/call section, with id 1.
You might expect the model to be the one calling our server. The phrase "the model calls a tool" is everywhere, including in this course. It is wrong as a description of who talks to whom. Look at what this message carries.
- The id is
1, chosen by Claude Code's client, the next number after thetools/listrequest's0. The model does not number MCP requests. - The
_metasays the sender is Claude Code 2.1.300, speaking 2026-07-28, able to show forms. Those are facts about the host. claudecode/toolUseIdis a key under Claude Code's own prefix. Its value,toolu_01BXSQEWH8rdfEBc5X1BXYYi, has the form of a tool use id from the Claude API. It is the id of the model's request in step 3 of the loop, which the host carried over so its own records can link the two.progressTokenasks the server for progress updates on this call, if it has any. Lesson 7 sends them.- The fields are in a different order from our hand-typed calls:
methodfirst,jsonrpcandidlast. JSON does not care about key order, and neither does any correct reader.
So the model wrote a tool use request in the Claude API's format, mcp__helpdesk__get_ticket with ticket_id "2318". Claude Code read it, decided it could go ahead, and built this MCP request from it. The right model: the model asks the host; the host's client calls the server. That split is why the host can check with the user first, why the host can refuse, and why the server never sees the conversation.
An Older Client: Codex
Not every host speaks 2026-07-28. Codex CLI 0.160.0, OpenAI's coding agent, was connected to the same server through the same tap on the same day. Its first message:
{
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {
"experimental": {
"codex/auth-change": {}
},
"elicitation": {
"form": {},
"url": {}
}
},
"clientInfo": {
"name": "codex-mcp-client",
"title": "Codex",
"version": "0.160.0"
}
}
}An initialize request asking for revision 2025-06-18. This is the old handshake, from the "What changed" note in lesson 2. Our server answers it with the old kind of result and switches this process to the old rules. Codex then sent notifications/initialized, and a tools/list whose only _meta was a progressToken. Then it sent a tools/call without the required _meta keys. Here it is, cut to the fields that matter:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"_meta": {
"callId": "exec-71404a0d-10fc-42ac-bcc9-964ebdf17dc5",
"x-codex-turn-metadata": { ... },
...
"progressToken": 1
},
"name": "get_ticket",
"arguments": {
"ticket_id": "2318"
}
}
}Under the current rules, that call would get -32602 for its missing _meta, as request 8 did in lesson 2. Under the old rules that this process now follows, it is valid, and Codex got ticket 2318 back, the same result as Claude Code.
Without the initialize branch, this would fail at the first message. The versioning page of the specification is direct about the combination of an older client and a server that speaks only the current revision: it "Fails", and "Legacy clients have no fall-forward mechanism." The initialize branch is the whole difference between a server that works in Codex and one that does not.
VS Code is in the same position. Its source code declares 2025-11-25 as the newest revision it speaks (checked 2026-10-15), so it opens with initialize too. It was not recorded for this lesson. The repository's README has the configuration, a .vscode/mcp.json file in the project folder:
{
"servers": {
"helpdesk": {
"type": "stdio",
"command": "node",
"args": ["${workspaceFolder}/handwritten/server.ts"]
}
}
}servers is VS Code's key for its list of servers. "type": "stdio" says to start the server as a child process. command and args are the command line, and ${workspaceFolder} is VS Code's name for the open folder.
Which era each host speaks changes from month to month. The server's first log line tells you, for any client, on the day you run it.
The Puzzle, Answered
When the model passes "#2318", the model fixes it, and the server makes that possible. The server answers with a tool error: a normal result with isError: true. Its message names the bad value and the expected format: "Ticket numbers are digits only, for example 2318." The specification says hosts SHOULD give that result to the model, and the host's loop gives the model another turn. That is how the model finds out.
A crash would have told the model nothing. A protocol error would have reached the model only if the host chose to pass it on. The kind of error is part of the design, not a detail.
The Takeaway
- Every tool call runs inside the host's loop: list, give the tools to the model, the model asks, the host checks, the client calls, the result goes back to the model, and the loop repeats until the model answers. Only
tools/listandtools/callcross the wire. - A server that has tools declares
"tools": {}in its capabilities. Itstools/listresult gives each tool aname,title,description,inputSchema, optionaloutputSchemaandannotations, plusttlMsandcacheScope, in a stable order. - The model sees only the name, the description and the input schema, rewritten into its model API's format. Put the argument format and an example in the description, and the rule in the schema.
- A
tools/callresult carriescontent(a list of blocks) and, when the tool has anoutputSchema,structuredContentthat matches it. Send both, and make each one complete: which one the model sees depends on the host. - A wrong argument is a tool error,
isError: truein a result, which hosts should pass to the model so it can correct itself. A broken request, such as an unknown tool, is a protocol error. An uncaught exception is the worst of the three. - Annotations are hints written by the server. A tool without them is assumed to be destructive and open-world. Clients must not trust them from servers they do not trust.
- The model never calls the server. It asks the host, and the host's client builds the MCP request. Claude Code opened with
server/discoverand Codex withinitialize, and our server answered both.
The one sentence to keep: the model knows a tool only by its name, description and schema, and learns about its mistakes only from results, so both must be written for the model to read.
An open question for the next lesson: our one tool costs 103 tokens on every model call. Tom's Claude Code will soon have our server next to five popular public ones. What does that list cost, and does a longer list change which tool the model picks? Tools cost context: choosing servers and keeping the list small.
References
Specification, revision 2026-07-28 (source at commit 86f6e0c675afea9a8f233e09b6faf566b126086c):
- Tools: the
toolscapability, the per-connection rule, deterministic order,tools/listandtools/call, tool name rules,inputSchemaandoutputSchema, content blocks, structured content and its text copy, the two kinds of error, "Clients SHOULD provide tool execution errors to language models", untrusted annotations, the message flow. Source:docs/specification/2026-07-28/server/tools.mdx - Schema:
ToolAnnotations: "all properties inToolAnnotationsare hints", the four hints and their defaults. Source:schema/2026-07-28/schema.ts - Base protocol:
resultTypeand the rule for results without it. Source:docs/specification/2026-07-28/basic/index.mdx - stdio transport: restart after an unexpected exit, the
server/discoverprobe. Source:docs/specification/2026-07-28/basic/transports/stdio.mdx - Versioning and compatibility: the compatibility matrix, "Legacy clients have no fall-forward mechanism". Source:
docs/specification/2026-07-28/basic/versioning.mdx - Caching:
ttlMsandcacheScopeontools/list. Source:docs/specification/2026-07-28/server/utilities/caching.mdx - Key changes in 2026-07-28: "All results now carry a required
resultTypefield". Source:docs/specification/2026-07-28/changelog.mdx - SEP-2322, multi round-trip requests: why results gained
resultType - The New MCP Roadmap (MCP blog, 2026-08-22): "a server developer today has no way to know which form a given client will put in front of the model"
Companion code, tag lesson-03 (karnstack/mcp-from-zero, MIT license; Node 24.14.1, typescript 6.0.3, @types/node 24.19.1, ajv 8.20.0, ajv-formats 3.0.1):
handwritten/server.ts: the server in this lessonwire/tap.ts: the logging pipe between a real client and the servertranscripts/lesson-03/: every session in this lesson, recorded 2026-10-15, including06-claude-code(Claude Code 2.1.300) and07-codex-legacy(Codex CLI 0.160.0)- The token counts:
o200k_basethroughjs-tiktoken1.0.21, with the repository'stools/tool-cost.ts(added at taglesson-04) run against this server
Clients, checked 2026-10-15:
- Tool reference (Claude API documentation): a tool definition has a
name, adescriptionand aninput_schema - Connect Claude Code to tools via MCP:
claude mcp add,mcp__<server>__<tool>names, the v2 runtime that speaks 2026-07-28 and negotiates down per server - Codex MCP documentation and the Codex source:
initializeat 2025-06-18 by default - MCP configuration reference (VS Code documentation): the
.vscode/mcp.jsonformat; VS Code source:LATEST_PROTOCOL_VERSION = "2025-11-25"insrc/vs/platform/mcp/common/modelContextProtocol.ts

