Module 6 · The Model Context Protocol (MCP) ⏱ 18 min

The Initialize Handshake and Capability Negotiation

By the end of this lesson you will be able to:
  • Implement the initialize request a client sends to start an MCP session
  • Build the server's initialize response and the client's initialized notification
  • Explain how capabilities declare what each side may be asked to do
  • Gate a method on whether the receiver declared the matching capability

Two strangers cannot start trading favours before they have introduced themselves. An MCP client and server are the same: before a single tool is listed or called, they run a short handshake that agrees on a protocol version and declares what each side can do. Everything you built in the last lesson — the request, the response, the notification — is exactly what the handshake is made of, so there is nothing new under the hood, only a fixed sequence of messages.

The handshake matters because it is where two independent programs decide they can talk at all. The client states the protocol version it speaks and the features it supports; the server answers with its own version and features. Skip it and the first tools/call could land on a server that does not understand tools, or a version the client never agreed to. The handshake makes that mismatch impossible to ignore.

flowchart LR
  C["client"] -->|"1. initialize request"| S["server"]
  S -->|"2. initialize response"| C
  C -->|"3. initialized notification"| S
  style C fill:#3776ab,color:#fff
  style S fill:#8b5cf6,color:#fff
The handshake is three messages: initialize request, initialize response, then the initialized notification.

The initialize request

The client opens with an initialize request. Because it expects an answer, this is a request, not a notification, so it carries an id. Its params hold three things: a protocolVersion string that is a date, such as 2025-06-18; a capabilities object listing the features the client supports; and a clientInfo object with a name and version.

The protocol version is the part to get right. It is date-based on purpose, so a client can say exactly which revision of the spec it targets, and the server can answer with the version it actually runs. The two need not match perfectly, but the client must be willing to work at the version the server returns, or decline and disconnect. Capabilities, meanwhile, are a promise: anything the client lists here it is prepared to handle, and anything it omits it is not.

Build the initialize request: a version, client capabilities, and clientInfo. Press Run.
function makeInitialize(clientInfo, capabilities, protocolVersion) {
  return {
    jsonrpc: '2.0',
    id: 0,
    method: 'initialize',
    params: {
      protocolVersion: protocolVersion,
      capabilities: capabilities,
      clientInfo: clientInfo
    }
  };
}

const req = makeInitialize({ name: 'my-client', version: '1.0.0' }, { roots: {} }, '2025-06-18');
console.log(req.method);
console.log(req.params.protocolVersion);
console.log(req.params.clientInfo.name);

The response and the initialized notification

The server answers the request with its own protocolVersion, its own capabilities, and a serverInfo object naming itself. At this point the client knows what the server offers and at which version. But the exchange is not finished: the client must send one more message, notifications/initialized, to confirm it is ready.

That last message is a notification, which means it has no id and expects no reply. It is a one-way ack. Only after it is sent do ordinary requests like tools/list and tools/call begin to flow. Splitting the handshake this way gives the client a moment to act on what the server declared before committing to the session, and it is why a server that starts receiving tools requests before initialized has arrived is correct to hold off.

The full dance: a request, a result, then a notification with no id. Run it.
// 1. Client sends initialize (a request, so it has an id).
const initReq = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'cli', version: '1.0.0' } } };
// 2. Server replies with its version, capabilities, and serverInfo.
const initResult = { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'demo-server', version: '0.1.0' } };
// 3. Client sends notifications/initialized (no id, so it is a notification).
const initialized = { jsonrpc: '2.0', method: 'notifications/initialized' };

console.log('request has id:', 'id' in initReq);
console.log('server offers tools:', 'tools' in initResult.capabilities);
console.log('initialized has id:', 'id' in initialized);
flowchart TD
  A["server declares capabilities"] --> B["tools, resources, prompts"]
  C["client declares capabilities"] --> D["roots, sampling, elicitation"]
  E["a capability NOT declared"] --> F["those methods stay off limits"]
  style F fill:#b91c1c,color:#fff
Each side declares capabilities; a capability not declared means those methods are never called.

Capabilities: declare it, or do not call it

Capabilities turn a feature into a rule. The server-side capabilities you will see most are tools, resources, and prompts; the client-side ones include roots, sampling, and elicitation. The contract is simple and strict: a side may be asked to perform a method only if it declared the matching capability. A server that did not list tools must never receive tools/call, and a client that did not list sampling must never be asked to sample.

This is why capabilities are checked, not assumed. A client that wants to call tools looks at the server's initialize response and confirms tools is present before sending tools/list. Absence is not an error to recover from, it is a statement: those methods do not exist on this server, full stop.

A capability the server did not declare means its methods are off limits. Run it.
// If the server did not declare a capability, those methods are off limits.
const serverCaps = { tools: {} };           // offers tools, nothing else
console.log('tools allowed:', 'tools' in serverCaps);
console.log('resources allowed:', 'resources' in serverCaps);
flowchart LR
  H["handshake: initialize then initialized"] --> G["the gate"]
  G --> R["normal requests now flow"]
  style G fill:#b45309,color:#fff
  style R fill:#166534,color:#fff
The handshake is a gate: ordinary requests flow only after initialize and initialized complete.
Exercise

What does this print? The initialize params carry the protocolVersion the client sends. There is one console.log.

function makeInitialize(clientInfo, capabilities, protocolVersion) {
  return { jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: protocolVersion, capabilities: capabilities, clientInfo: clientInfo } };
}
const req = makeInitialize({ name: 'my-client', version: '1.0.0' }, {}, '2025-06-18');
console.log(req.params.protocolVersion);
Exercise

Write makeInitialize(clientInfo, capabilities, protocolVersion) that returns a JSON-RPC initialize request: version tag, an id of 0 (it is a request), method 'initialize', and params holding protocolVersion, capabilities, and clientInfo.

function makeInitialize(clientInfo, capabilities, protocolVersion) {
  // jsonrpc 2.0, id 0, method initialize, params with the three fields
}
Exercise

notifications/initialized must be a NOTIFICATION: it must NOT carry an id, because no reply is expected. This version attaches an id, which makes the client block forever waiting for a response that never comes. Remove the field that turns a notification into a request.

function makeInitialized() {
  return { jsonrpc: '2.0', id: 1, method: 'notifications/initialized' };
}
Exercise

Write serverAllows(serverCaps, method) that returns true only when the server has declared the capability the method belongs to. Map tools/list and tools/call to the tools capability, resources/list and resources/read to resources, and prompts/list and prompts/get to prompts. Any other method returns false.

function serverAllows(serverCaps, method) {
  // true only when the matching capability was declared
}
Exercise

After the client receives the server's initialize response, what must it send before ordinary requests like tools/list can flow?

Recap

  • The handshake is three JSON-RPC messages: an initialize request (has id), an initialize response, and a notifications/initialized notification (no id).
  • The initialize request carries a date-based protocolVersion, the client's capabilities, and clientInfo; the response echoes a version and adds serverInfo.
  • Capabilities are a rule, not a hint: a method may be called only if the receiver declared the matching capability.
  • Server capabilities are tools, resources, prompts; client capabilities include roots, sampling, elicitation.
  • Never give initialized an id — a notification identified by its missing id expects no reply, and adding one deadlocks the session.

Next you will put tools, resources, and prompts on the table and implement the tools/list and tools/call handlers a server uses to actually offer them.

Checkpoint quiz

Why does the initialize request carry an id while notifications/initialized does not?

A server's initialize response lists no tools capability. What is the correct conclusion?

Go deeper — technical resources