The Protocol Underneath the SDK
When you call server.setRequestHandler(...) in the SDK, you are working several layers above the actual wire. Underneath, MCP is just JSON-RPC 2.0 messages flowing over a transport. Understanding that wire format is what separates someone who can copy an example from someone who can debug a hung handshake, negotiate the right protocol version, or build a client from scratch. In this lesson we peel back the SDK and look at the exact bytes a client and server exchange — the message envelope, the initialization handshake, capability negotiation, error semantics, and the transport layers that carry it all.
- Distinguish JSON-RPC requests, responses, and notifications on the wire
- Walk through the initialize handshake and protocol version negotiation
- Read and declare a capabilities object correctly
- Interpret JSON-RPC error codes and lifecycle notifications
- Choose between stdio and Streamable HTTP transports
JSON-RPC 2.0: The Message Envelope
Every MCP message is a JSON object with "jsonrpc": "2.0". There are exactly three shapes, and telling them apart is the single most important skill for reading a protocol trace.
A request has an id, a method, and optional params. It expects a response.
json{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }
A response echoes the same id and carries either result or error — never both.
json{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ { "name": "get_weather", "description": "Fetch the current weather for a city.", "inputSchema": { "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] } } ] } }
A notification looks like a request but has no id. It is fire-and-forget — the receiver must never reply to it.
json{ "jsonrpc": "2.0", "method": "notifications/initialized" }
Pro Tip
id is the whole game: if a message has an id, someone owes a response with that same id. If it has no id, it is a notification and replying to it is a protocol violation.The Initialization Handshake
No tools/call or resources/read may happen until the connection is initialized. The handshake is a strict three-step dance:
- The client sends an
initializerequest. - The server replies with its capabilities and chosen protocol version.
- The client sends a
notifications/initializednotification to confirm it is ready.
The client's initialize request advertises the newest protocol version it supports and the capabilities it offers (like sampling and roots):
json{ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": { "roots": { "listChanged": true }, "sampling": {} }, "clientInfo": { "name": "claude-desktop", "version": "1.0.0" } } }
The server responds with the version it will actually speak (which may be older if it does not support the client's requested one), the capabilities it exposes, and an optional instructions string the host can inject into the model's context:
json{ "jsonrpc": "2.0", "id": 0, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": true, "listChanged": true }, "prompts": { "listChanged": true }, "logging": {} }, "serverInfo": { "name": "weather-mcp", "version": "1.0.0" }, "instructions": "Call get_weather for live conditions before answering weather questions." } }
Warning
2024-11-05 or 2025-06-18, not semver. If the server returns a protocolVersion the client cannot support, the client should disconnect rather than continue. Never assume both sides landed on the same version — always read what the server echoed back.Capability Negotiation
The capabilities object is a contract. A feature that is not declared must not be used. An empty object like "logging": {} means "I support this feature with no sub-options." Nested flags refine the behaviour:
tools.listChanged— the server will emitnotifications/tools/list_changedwhen its tool set changes.resources.subscribe— clients may callresources/subscribeto receivenotifications/resources/updated.resources.listChanged/prompts.listChanged— dynamic list-change notifications for those primitives.
In the TypeScript SDK you declare these when constructing the server, and the SDK refuses to send notifications you never advertised:
typescriptconst server = new Server( { name: "weather-mcp", version: "1.0.0" }, { capabilities: { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true }, prompts: { listChanged: true }, logging: {}, }, }, )
Note
tools, resources, prompts, and logging; clients declare sampling (letting the server ask the host's model to generate text) and roots (exposing filesystem boundaries). Each side must check what the other declared before invoking a related method.Requests, Responses, and Errors
Because id correlates a response to its request, a client can have many requests in flight at once and match replies as they arrive — order is not guaranteed. When something goes wrong, the server returns an error object instead of result, using standard JSON-RPC codes:
json{ "jsonrpc": "2.0", "id": 2, "error": { "code": -32602, "message": "Invalid params: 'city' is required", "data": { "missingField": "city" } } }
| Code | Meaning | Typical cause |
|---|---|---|
-32700 | Parse error | Malformed JSON on the wire |
-32600 | Invalid Request | Missing jsonrpc or method |
-32601 | Method not found | Unknown method name |
-32602 | Invalid params | Arguments failed schema validation |
-32603 | Internal error | Unhandled server exception |
Warning
result with isError: true inside the content, not a -326xx error. That lets the model see and reason about the failure instead of the connection tearing down.Transport Layers
JSON-RPC defines the messages; a transport defines how those messages travel. MCP standardizes two.
Best for local servers. The client launches your server as a subprocess and exchanges newline-delimited JSON over stdin/stdout. Each message is one line — embedded newlines are not allowed. Anything the server writes to stderr is treated as logging and never parsed as protocol.
typescriptimport { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" const transport = new StdioServerTransport() await server.connect(transport)
Notifications and Lifecycle Events
Beyond initialized, notifications drive the live behaviour of a session. Because they carry no id, they never block anything — they simply inform.
json{ "jsonrpc": "2.0", "method": "notifications/progress", "params": { "progressToken": "scan-42", "progress": 60, "total": 100 } }
Common ones you will see in a trace:
notifications/progress— incremental progress for a long-running request (tied to aprogressTokenthe caller supplied in_meta).notifications/cancelled— the caller aborted a request; the receiver should stop work for thatrequestId.notifications/message— structured server logs (level,logger,data) delivered to the client.notifications/tools/list_changed— re-fetchtools/list; the available tools changed.
Common Pitfalls
Warning
id, sending any response — even an error — breaks the spec and confuses well-behaved clients. Route by presence of id first.- Printing to stdout on a stdio transport. A stray
console.logwrites non-JSON to the exact stream carrying protocol messages and corrupts the session. Useconsole.error(stderr) or theloggingcapability instead. - Calling methods before
initialized. Any request other thaninitialize(andping) before the handshake completes is invalid. Wait for the client'snotifications/initializedbefore treating the server as ready. - Using an undeclared capability. Emitting
notifications/resources/updatedwithout declaringresources.subscribe, or callingsampling/createMessagewhen the client never advertisedsampling, will be rejected. - Assuming the requested protocol version won. Always branch on the
protocolVersionthe server actually returned, not the one you asked for.
Next Steps
Next Lesson
Resources & Prompts