Build your own Claude Code
MCP client·28 min

tools/list + tools/call

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

The handshake is done; now the payoff. tools/list asks the server what it offers — a list of tool definitions, each with a name, description, and JSON-Schema inputSchema. tools/call invokes one by name with arguments, and the server returns content (the result blocks) plus an isError flag. You turn that content into text and hand it back to the model as a tool_result — the very same shape your own tools produced back in Chapter 1, now sourced from a separate process.

This step closes the loop: discover, call, surface the result.

discover, then call — and unwrap the result
host → tools/list            → server → result.tools: [{ name, description, inputSchema }, ...]
host → tools/call {name,args} → server → result: { content:[{type:"text",text}], isError:false }
                                                    └── parseToolResult → the text (or throw if isError) ──┘

What to build

Your task

Implement the tool builders and parseToolResult

In src/mcp.ts, build the two requests and the result parser:

  • toolsListRequest(id) — a JSON-RPC request, method: 'tools/list', params: {}.
  • toolsCallRequest(id, name, args) — method: 'tools/call', params: { name, arguments: args }. Note the param is literally named arguments, not args.
  • parseToolResult(resp) — read resp.result; if isError is true, throw with the error text from content[0].text; otherwise return that text. Throw if there's no result at all.

Graded by program.ts mcp-call: it stands up a server from the fixture's serverTools, sends tools/list then tools/call(tool, args) through your builders, and prints { toolsListRequest, toolsCallRequest, toolNames, callResult }.

src/mcp.ts
export function toolsListRequest(id: number): JsonRpcRequest {
  // -> { jsonrpc:'2.0', id, method:'tools/list', params:{} }
}

export function toolsCallRequest(id: number, name: string, args: Record<string, unknown>): JsonRpcRequest {
  // -> { jsonrpc:'2.0', id, method:'tools/call', params:{ name, arguments: args } }
}

/** Unwrap a tools/call result to its text (throw on isError). */
export function parseToolResult(resp: JsonRpcResponse): string {
  // const r = resp.result as { content?: {type,text}[]; isError?: boolean }
  // if (!r) throw ...;  if (r.isError) throw ...;  return r.content?.[0]?.text ?? ''
}
parseToolResult is the bridge back to the agent loop

An MCP tool result and a native tool_result carry the same payload: text content and a success/error flag. parseToolResult is the adapter — it collapses { content, isError } into the string your loop already knows how to put in a tool_result block. So the model never knows or cares that this tool lives in another process; it sees the same observation it'd get from bash or view. That's the whole point of MCP: new tools, no new loop code.

`arguments`, not `args` — and isError throws

Two graded gotchas. The tools/call param is named arguments in the spec — params: { name, arguments: args }. Calling the local variable args is fine; serializing it under the key args is wrong and the frame diffs. Second, parseToolResult must throw when isError is true rather than returning the error text as if it were success — a failed tool call should surface as an error to the loop, not a quietly wrong observation.

How it's graded

Process mode: your_program.sh mcp-call runs program.ts mcp-call on the case fixture and prints { toolsListRequest, toolsCallRequest, toolNames, callResult }. The grader matches fixtures/expected.json exactly — the two request frames (id 2 for list, 3 for call; params:{"name":"echo","arguments":{"message":"hi"}}), the toolNames parsed from tools/list (["echo"]), and callResult, the text parseToolResult unwrapped from the call (echo({"message":"hi"})). A misnamed param or an unthrown error diffs immediately.