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 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.
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.
// 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
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.
// 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
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);
The protocolVersion argument is stored at params.protocolVersion.
It is returned unchanged, so print exactly the value passed in.
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
}
Return an object with jsonrpc, id 0, method initialize, and a params object.
params nests protocolVersion, capabilities, and clientInfo by those exact keys.
function makeInitialize(clientInfo, capabilities, protocolVersion) {
return {
jsonrpc: '2.0',
id: 0,
method: 'initialize',
params: {
protocolVersion: protocolVersion,
capabilities: capabilities,
clientInfo: clientInfo
}
};
}
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' };
}
A notification is a request with the id removed.
Delete the id key from the returned object so it is absent entirely.
function makeInitialized() {
return { jsonrpc: '2.0', method: 'notifications/initialized' };
}
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
}
Group the methods by the capability they require.
For each group, return whether that capability key is present in serverCaps; unknown methods return false.
function serverAllows(serverCaps, method) {
if (method === 'tools/list' || method === 'tools/call') return 'tools' in serverCaps;
if (method === 'resources/list' || method === 'resources/read') return 'resources' in serverCaps;
if (method === 'prompts/list' || method === 'prompts/get') return 'prompts' in serverCaps;
return false;
}
After the client receives the server's initialize response, what must it send before ordinary requests like tools/list can flow?
The handshake closes with the initialized notification. It carries no id precisely because no reply is expected; only once it is sent does the session open for normal requests.
Recap
- The handshake is three JSON-RPC messages: an
initializerequest (has id), an initialize response, and anotifications/initializednotification (no id). - The initialize request carries a date-based
protocolVersion, the client'scapabilities, andclientInfo; the response echoes a version and addsserverInfo. - 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 includeroots,sampling,elicitation. - Never give
initializedan 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.