Build your own Claude Code
MCP client·22 min

The handshake

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

An MCP connection opens with a handshake, and nothing — no tool list, no call — happens before it completes. The host sends initialize: here's the protocol version I speak, here's who I am, and here's what I'm capable of. The server replies with the mirror image: the version it agreed on and the capabilities it offers. From that reply you learn whether the server even has tools to list.

This step you build the request side of the handshake and parse the server's capabilities out of its response.

initialize: host declares, server declares back
host → {"jsonrpc":"2.0","id":1,"method":"initialize","params":{
          protocolVersion:"2025-11-25",
          capabilities:{ roots:{}, sampling:{} },
          clientInfo:{ name:"claude-code", version:"1.0.0" } }}

server → {"jsonrpc":"2.0","id":1,"result":{ capabilities:{ tools:{ listChanged:false } }, ... }}
                                                 └── parse this out ──┘

What to build

Your task

Implement initializeRequest and parse server capabilities

In src/mcp.ts, build the handshake request:

  • initializeRequest(id) — return a JSON-RPC request with method: 'initialize' and params carrying three fields: protocolVersion (the PROTOCOL_VERSION constant, '2025-11-25'), capabilities (what the host offers — { roots: {}, sampling: {} }), and clientInfo ({ name: 'claude-code', version: '1.0.0' }). Set jsonrpc: '2.0' and the given id.
  • Then, from the server's response, pull out its capabilities: serverResponse.result.capabilities (or null if there's no response).

Graded by program.ts mcp-init, which calls initializeRequest(id) from the case fixture, reads serverResponse.result.capabilities, and prints { request, capabilities }.

src/mcp.ts
export const PROTOCOL_VERSION = '2025-11-25'

export function initializeRequest(id: number): JsonRpcRequest {
  // -> { jsonrpc:'2.0', id, method:'initialize',
  //      params: { protocolVersion, capabilities: { roots:{}, sampling:{} }, clientInfo: { name, version } } }
}

// In program.ts the parse is just: serverResponse?.result?.capabilities ?? null
Capabilities are a two-way declaration

The handshake is symmetric. The host advertises what it can do for the server — here roots (it can expose filesystem roots) and sampling (it can run model completions on the server's behalf). The server advertises what it offers the host — tools, resources, prompts. Each side reads the other's set and only uses features both agreed on. For this chapter the field that matters in the reply is tools: its presence is your signal that tools/list is worth sending next.

Echo the id, and don't act before the reply

Two protocol rules to respect. The response must carry the same id as your request — that's what lets you match it; don't hard-code or drop it. And initialize gates everything: a real host sends no tools/list or tools/call until the server's initialize response arrives. The grader doesn't enforce ordering here, but build the mental model now — the next two steps assume the handshake already happened.

How it's graded

Process mode: your_program.sh mcp-init runs program.ts mcp-init on the case fixture and prints { request, capabilities }. The grader matches fixtures/expected.json exactly: request must be the full initialize frame (protocolVersion:"2025-11-25", capabilities:{roots:{},sampling:{}}, clientInfo:{name:"claude-code",version:"1.0.0"}, matching id), and capabilities must be the server set you parsed out ({"tools":{"listChanged":false}}). A wrong protocol version or a missing client field diffs on the spot.