Protocols are not magic. Under every elegant abstraction is a set of bytes moving between processes, governed by rules that someone wrote down. MCP is no different. The moment you understand exactly what happens on the wire when a client connects to an MCP server – the handshake, the capability negotiation, the request-response cycle, the message format – the whole protocol becomes transparent. And transparent systems are debuggable systems.

JSON-RPC 2.0: The Wire Format
MCP uses JSON-RPC 2.0 as its message format. JSON-RPC is a simple remote procedure call protocol encoded as JSON. Every MCP message is one of four types: a Request, a Response, an Error Response, or a Notification (a one-way message with no response expected).
A JSON-RPC request looks like this:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "greet",
"arguments": { "name": "Alice" }
}
}
A successful response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{ "type": "text", "text": "Hello, Alice! Welcome to MCP." }
]
}
}
An error response:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32602,
"message": "Invalid params",
"data": { "detail": "Missing required argument: name" }
}
}
A notification (no id, no response expected):
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
}
The id field is how you match responses to requests in an async system. If you send ten requests and get ten responses back in any order, the id tells you which response belongs to which request. Notifications don’t have IDs because they are fire-and-forget.
“The base protocol uses JSON-RPC 2.0 messages exchanged over a transport layer. Server and client capabilities are negotiated during an initialization phase.” – MCP Specification, Base Protocol
The Connection Lifecycle
Every MCP connection goes through a well-defined lifecycle. Understanding this lifecycle is essential for debugging connection problems and for building robust hosts and servers.
The lifecycle has three phases: initialisation, operation, and shutdown.
Phase 1: Initialisation
When a client connects to a server, the very first thing that happens is the initialisation handshake. This is not optional and not configurable – it is the protocol’s way of ensuring both sides agree on what they can do together before anything else happens.
The sequence:
- Client sends an
initializerequest, declaring its protocol version and capabilities. - Server responds with its protocol version and capabilities.
- Client sends an
initializednotification to confirm it received the response. - Both sides are now in the operating phase and can exchange any supported messages.
// Step 1: Client sends initialize request
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"clientInfo": { "name": "my-host", "version": "1.0.0" },
"capabilities": {
"sampling": {}
}
}
}
// Step 2: Server responds
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"serverInfo": { "name": "my-server", "version": "1.0.0" },
"capabilities": {
"tools": {},
"resources": { "subscribe": true },
"logging": {}
}
}
}
// Step 3: Client confirms with a notification
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}

Phase 2: Operation
After the handshake, the connection is fully operational. Either side can send requests, responses, or notifications, subject to the capabilities they negotiated. The client can call tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get. The server can send sampling/createMessage requests (if the client declared sampling capability) or notifications about state changes.
Phase 3: Shutdown
Either side can close the connection. With stdio transport, this happens when the process exits. With HTTP/SSE transport, it happens when the connection is closed. The MCP SDK handles shutdown gracefully when you call server.close() or client.close().
Capability Negotiation in Detail
Capability negotiation determines what each side is allowed to do in the operation phase. If the client does not declare sampling capability, the server cannot send sampling/createMessage requests – the client simply will not handle them. If the server does not declare resources capability, the client calling resources/list will receive a method-not-found error.
This is a safety mechanism. It prevents servers from sending requests that clients cannot handle, and prevents clients from calling methods the server has not implemented. You can inspect negotiated capabilities after connecting:
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const client = new Client(
{ name: 'inspector', version: '1.0.0' },
{ capabilities: { sampling: {} } }
);
const transport = new StdioClientTransport({
command: 'node',
args: ['./my-server.js'],
});
await client.connect(transport);
// Read back what the server declared it supports
const serverCaps = client.getServerCapabilities();
console.log('Server capabilities:', JSON.stringify(serverCaps, null, 2));
// Read back what the server reported about itself
const serverInfo = client.getServerVersion();
console.log('Server info:', serverInfo);
Common Protocol Failures
Case 1: Sending Requests Before Initialisation Completes
If you attempt to call a tool or list resources before the initialized notification has been exchanged, the server will reject the request with a protocol error. The MCP SDK handles this transparently when you use client.connect(transport) – the connect method waits for the full handshake before resolving. But if you implement a custom transport or bypass the SDK, this is the first thing that will bite you.
// WRONG: Calling tools before connect resolves
const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] });
client.connect(transport); // Don't await this
const tools = await client.listTools(); // Races with initialisation - will fail
// CORRECT: Always await connect
await client.connect(transport);
const tools = await client.listTools(); // Safe: handshake is complete
Case 2: Mismatched Protocol Versions
MCP has versioned specifications. If client and server declare different protocol versions and neither can support the other, the connection fails at initialisation. The current stable version is 2024-11-05; the draft spec has incremental additions. When upgrading SDK versions, check the protocol version the new SDK defaults to and ensure your deployed servers are compatible.
// Always pin to a specific protocol version in production:
const client = new Client(
{ name: 'my-host', version: '1.0.0' },
{
capabilities: {},
// The SDK picks the protocol version based on what server supports
// Check SDK changelog when upgrading
}
);
Case 3: Treating Notifications Like Requests
Notifications do not have an id field and do not receive a response. If you send a notification and wait for a response, you will wait forever (or until a timeout). The SDK’s notification methods do not return promises for this reason – they are fire-and-forget by design.
// WRONG: Awaiting a notification
await client.sendNotification({ method: 'notifications/initialized' }); // This does not wait for a response
// CORRECT: Notifications are void
client.sendNotification({ method: 'notifications/initialized' }); // Correct - no await
“Servers MUST NOT send requests to clients before receiving the initialized notification from the client. Clients MUST NOT send requests other than pings before receiving the initialize response from the server.” – MCP Specification, Lifecycle
What to Check Right Now
- Inspect live traffic with the MCP Inspector – run
npx @modelcontextprotocol/inspector node your-server.js. The inspector shows every JSON-RPC message, making the protocol visible in real time. - Enable SDK logging – set
DEBUG=mcp:*in your environment when developing. The SDK logs all protocol messages, which is invaluable for debugging lifecycle issues. - Validate your capability declarations – if a server feature is not working, check that the server declared the capability and the client did not need to declare a matching client capability first.
- Use JSON-RPC error codes correctly – MCP defines standard error codes (
-32700parse error,-32600invalid request,-32601method not found,-32602invalid params,-32603internal error). Application-level errors use codes in the range-32000to-32099.
nJoy 😉
