Build your own Claude Code
MCP server·22 min

tools/call handler

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage tools-call-handler --watch

initialize and tools/list are conversation. tools/call is where work actually happens — the client names a tool, hands you arguments, and expects you to run it and report back. This is the method your Chapter 9 client spent a whole step learning to send; now you answer it.

The subtlety is that there are two different kinds of failure, and MCP draws a sharp line between them. A tool that runs but fails — bad input, a missing file, an unknown tool name — is a successful JSON-RPC response carrying isError: true inside its result. The model is meant to see that text and recover. A request you can't even route — an unknown method — is a protocol failure, and that gets a JSON-RPC error frame instead. Same wire, two layers; conflating them is the classic MCP-server bug.

tool error vs protocol error
tools/call, unknown TOOL  →  result: { content:[{type:"text", text:"unknown tool: nope"}], isError:true }
                              (a normal response — the model reads it and recovers)

unknown METHOD            →  error:  { code:-32601, message:"Method not found" }
                              (a JSON-RPC error frame — the request couldn't be routed)

What to build

Your task

Handle tools/call and unknown methods

Extend handle(request) in src/mcp.ts to cover tools/call and to reject unknown methods.

  • tools/call → read params.name and params.arguments. If the named tool isn't one you declared, return a result with isError: true and content: [{ type: 'text', text: 'unknown tool: ' + name }] — a successful response carrying a tool-level error. If the tool exists, execute it and return content: [{ type: 'text', text: <output> }] with isError: false. For the echo tool the output is `${name}(${JSON.stringify(args)})` — e.g. echo({"x":1}).
  • Unknown method (anything that isn't initialize, tools/list, or tools/call) → return a JSON-RPC error frame: { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Method not found' } }. Note error, not result.

Validate before you execute: a missing tool is a tool error, not a thrown exception. Graded by program.ts mcp-server.

src/mcp.ts
// inside handle(req), after the initialize / tools/list cases:

if (req.method === 'tools/call') {
  const { name, arguments: args } = req.params as { name: string; arguments: Record<string, unknown> }
  const tool = this.tools.find(t => t.name === name)
  if (!tool) {
    // result: { content: [{ type:'text', text:`unknown tool: ${name}` }], isError: true }
  }
  // const text = `${name}(${JSON.stringify(args)})`
  // result: { content: [{ type:'text', text }], isError: false }
}

// fell through every known method -> JSON-RPC error frame:
// { jsonrpc:'2.0', id: req.id, error: { code:-32601, message:'Method not found' } }
isError lives in the result, not the frame

An unknown tool is not a broken request — the client asked a well-formed tools/call, you just couldn't satisfy it. So it's a normal JSON-RPC response (result, matching id) that happens to set isError: true. That's deliberate: the host threads the content text straight back to the model as a tool_result, and the model reads unknown tool: nope and tries something else. Demote it to a protocol error frame and the model never sees the message — it just sees the call vanish.

Reserve the error frame for unroutable methods

-32601 Method not found is JSON-RPC's code for I don't know this method. Use it only for the method you can't route — never for a tool that ran and failed. The grader sends an unknown tool (nope) expecting an isError: true result, and the spec reserves the error frame for things like an unknown method. Returning an error frame for the unknown tool — or a result for a genuinely unknown method — is the mismatch this step is built to catch.

How it's graded

Process mode. program.ts mcp-server builds the server from serverTools (one echo tool) and runs case.json's two tools/call requests: a valid echo({ x: 1 }) and a call to a nonexistent nope. It prints { responses: [...] }, compared to fixtures/expected.json exactly: id 1 → result: { content:[{type:'text', text:'echo({"x":1})'}], isError:false }, and id 2 → result: { content:[{type:'text', text:'unknown tool: nope'}], isError:true }. Both are result responses here — the error-frame path is exercised by the unknown-method case behind the scenes.