Loading video…
Sign in to react
The protocolSolidCode at lesson-03

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:

JSON
{"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.

ThingValue
our helpdesk serverhandwritten/server.ts, still no SDK, 152 lines at tag lesson-03
its one toolget_ticket, argument ticket_id, digits only
tickets it knows2318, 2319 and 2320, a stand-in for the database; lesson 5 adds the real 12,400
ticket 2318Blue Heron Yoga, customer cus_1042, "Charged twice for October", open
hosts recordedClaude Code 2.1.300 and Codex CLI 0.160.0, on 2026-10-15
protocol revision2026-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:

Text
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  -> host

Two 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:

Text
"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:

JSON
{
  "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:

JSON
{
  "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 a get_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 the arguments a 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 property ticket_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 the tools/call section 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:

Text
ttlMs = 300,000 ms = 300 s = 5 minutes

So 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):

Text
tool list (get_ticket only), as one JSON array   91 tokens
server instructions                              12 tokens
total                                           103 tokens, sent again on every model call

A 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:

JSON
{
  "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:

JSON
{
  "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
  }
}
  • content is a list of content blocks. Each block has a type. This one is text, and its text is the ticket written as a JSON string. Other types exist: image and audio carry base64 data, resource_link points to a resource, and resource embeds one. Lesson 6 uses the last two.
  • structuredContent is the same ticket as a JSON value, not as text. Because the tool declared an outputSchema, 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, and status is one of the three allowed values.
  • isError: false says 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 resultType field: a tools/call result began directly with content. 2026-07-28 made resultType required 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 absent resultType as "complete"." You will recognise an old server by a result with no resultType. Our own answer to initialize, 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:

JSON
{
  "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:

  1. "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/call itself, or a server failure. They are JSON-RPC errors.
  2. "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:

JSON
{
  "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 wrongKindOn the wireWho can fix it
an argument the model chose ("#2318", a ticket that does not exist)tool errorresult with isError: truethe model, on its next call, if the message says how
the request itself (unknown tool, missing _meta, not valid JSON)protocol errorJSON-RPC error with a codethe client's code, not the model
the server's code throws and nobody catches itneither: the process endsnothing for that idnobody, 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:

Text
"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:

HintMeaning when trueDefault when missing
readOnlyHintdoes not modify its environmentfalse
destructiveHintmay perform destructive updates (only meaningful when not read-only)true
idempotentHintcalling again with the same arguments has no additional effectfalse
openWorldHintinteracts with an open world of external entitiestrue

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

TypeScript
// 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

TypeScript
// 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 } });
}
  • TICKETS holds three tickets, keyed by their number as a string. Each value already has the shape the outputSchema promises.
  • reply writes a finished result. It adds resultType: "complete" to whatever result it is given, so no branch can forget it. send is the same one-line writer from lesson 2.

Two New Methods

In handle, after the server/discover branch, two lines route the new methods:

TypeScript
if (method === "tools/list") return reply(id, { tools: TOOLS, ttlMs: 300000, cacheScope: "public" });
if (method === "tools/call") return callTool(id, params);
  • tools/list returns the constant, with the 5 minute cache hint. The _meta checks from lesson 2 have already run by the time this line is reached.
  • tools/call hands the request to callTool.

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

TypeScript
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 if is the protocol error. Any name other than get_ticket gets -32602 and "Unknown tool", the get_tickets answer above.
  • params.arguments?.ticket_id reads the argument. The ?. means a missing arguments gives undefined instead of a crash.
  • Object.hasOwn(TICKETS, ticketId) checks that the key is one of our own tickets. A plain TICKETS[ticketId] would also find properties that every JavaScript object inherits, such as constructor. 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, 9999 and constructor all 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 in structuredContent, and isError: 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

TypeScript
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:

Shell
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:

Shell
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:

Text
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:

JSON
{
  "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 the tools/list request's 0. The model does not number MCP requests.
  • The _meta says the sender is Claude Code 2.1.300, speaking 2026-07-28, able to show forms. Those are facts about the host.
  • claudecode/toolUseId is 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.
  • progressToken asks 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: method first, jsonrpc and id last. 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:

JSON
{
  "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:

Text
{
  "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:

JSON
{
  "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

  1. 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/list and tools/call cross the wire.
  2. A server that has tools declares "tools": {} in its capabilities. Its tools/list result gives each tool a name, title, description, inputSchema, optional outputSchema and annotations, plus ttlMs and cacheScope, in a stable order.
  3. 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.
  4. A tools/call result carries content (a list of blocks) and, when the tool has an outputSchema, structuredContent that matches it. Send both, and make each one complete: which one the model sees depends on the host.
  5. A wrong argument is a tool error, isError: true in 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.
  6. 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.
  7. The model never calls the server. It asks the host, and the host's client builds the MCP request. Claude Code opened with server/discover and Codex with initialize, 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):

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 lesson
  • wire/tap.ts: the logging pipe between a real client and the server
  • transcripts/lesson-03/: every session in this lesson, recorded 2026-10-15, including 06-claude-code (Claude Code 2.1.300) and 07-codex-legacy (Codex CLI 0.160.0)
  • The token counts: o200k_base through js-tiktoken 1.0.21, with the repository's tools/tool-cost.ts (added at tag lesson-04) run against this server

Clients, checked 2026-10-15: