Module 4: The Protocol Layer
Duration: ~25 minutes | Level: Intermediate | Prerequisites: Module 3: Tools, Resources, and Prompts
The JSON-RPC envelope below is unchanged across every MCP revision. The MCP-specific method tables and the initialize walkthrough describe revision 2025-11-25, which is what the Java MCP SDK 2.0.0 implements and what most deployed servers speak.
Revision 2026-07-28 changed this layer substantially. It removed initialize, notifications/initialized, ping and logging/setLevel outright; replaced resources/subscribe with subscriptions/listen; added a required resultType field to every result ("complete" or "input_required", with an absent value read as "complete"); and requires every request to carry io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in params._meta, rejecting anything that omits them with -32602. It also reserved -32020 to -32099 for spec-defined error codes. The sections below flag the changes where they matter; Module 8 has the full list.
JSON-RPC 2.0: The Envelope
MCP messages are encoded as JSON-RPC 2.0. This is not a novel invention; JSON-RPC is a simple, well-understood RPC specification that predates MCP. Using it was a deliberate choice: it's language-agnostic, text-based, and has implementations in every ecosystem.
If you're unfamiliar with JSON-RPC, the spec is short enough to read in one sitting (a single web page at jsonrpc.org/specification). The essentials:
Requests
A request has a method, optional params, and an id:
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "search_products",
"arguments": {
"query": "ergonomic keyboard",
"max_price": 200
}
}
}
The id correlates a request with its response. A message with no id is a notification rather than a request, a convention MCP inherits from base JSON-RPC unchanged. What MCP tightens is the id itself: it must be a string or integer, must not be null (base JSON-RPC allows null here but discourages it, because null is reserved for responses to requests whose id couldn't be read), and must not have been used before by the sender within the session (client and server each track their own). Clients commonly satisfy this by incrementing a counter, but the protocol only requires the id to be unique, not increasing.
Responses
A response echoes the id and contains either a result or an error:
{
"jsonrpc": "2.0",
"id": 42,
"result": {
"content": [
{
"type": "text",
"text": "[{\"id\":\"kb-001\",\"name\":\"ErgoDox EZ\",\"price\":199.99}]"
}
],
"isError": false
}
}
Notifications
Notifications look like requests but carry no id, and that absence is exactly what makes them notifications. The receiver must not reply:
{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {
"uri": "file:///project/config.yaml"
}
}
MCP Message Taxonomy
MCP defines specific methods on top of JSON-RPC. They're organised into logical groups:
Lifecycle Methods
This is the group revision 2026-07-28 changed most: it deleted the group. Which shape you meet on the wire depends on the revision the two sides speak, and the spec names the two eras legacy (2025-11-25 and earlier, which open with a handshake) and modern (2026-07-28 and later, which do not).
Legacy (2025-11-25 and earlier). What the Java MCP SDK 2.0.0 implements, so what most deployed code still speaks:
| Method | Direction | Purpose |
|---|---|---|
initialize | Client → Server | Start a session, negotiate version + capabilities |
notifications/initialized | Client → Server | Acknowledge server's initialize response (notification) |
ping | Either direction | Keep-alive / latency check |
Modern (2026-07-28). There is no handshake. All three methods above were removed by SEP-2575 and none appears in the 2026-07-28 schema. None is merely deprecated either, unlike Roots, Sampling and Logging, which keep a guaranteed twelve-month window before they can be taken away. Every request instead carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in its _meta (both required), and SHOULD carry io.modelcontextprotocol/clientInfo. One new method takes over the discovery half of the old handshake:
| Method | Direction | Purpose |
|---|---|---|
server/discover | Client → Server | Report the server's supported versions, capabilities, identity, and optional instructions. Servers MUST implement it; calling it is optional for clients |
Why remove these rather than deprecate them? The handshake existed to establish per-connection state, and the new revision forbids exactly that: servers MUST NOT rely on prior requests over the same connection to establish context. A message whose only job is to set up state that may no longer be relied upon has nothing left to do. ping went with it, because a broken Streamable HTTP stream now simply loses the in-flight request, which the client re-issues with a new request id, so liveness became the transport's concern rather than the protocol's.
Tool Methods
| Method | Direction | Purpose |
|---|---|---|
tools/list | Client → Server | Discover available tools |
tools/call | Client → Server | Execute a specific tool |
notifications/tools/list_changed | Server → Client | Notify that tool list has changed |
Resource Methods
| Method | Direction | Purpose |
|---|---|---|
resources/list | Client → Server | Discover available resources |
resources/read | Client → Server | Fetch resource content |
resources/subscribe | Client → Server | Subscribe to change notifications |
notifications/resources/updated | Server → Client | Notify that a resource changed |
notifications/resources/list_changed | Server → Client | Notify that the resource list changed |
Prompt Methods
| Method | Direction | Purpose |
|---|---|---|
prompts/list | Client → Server | Discover available prompts |
prompts/get | Client → Server | Fetch a specific prompt (with arguments) |
notifications/prompts/list_changed | Server → Client | Notify that prompt list changed |
Utility Methods
| Method | Direction | Purpose |
|---|---|---|
logging/setLevel | Client → Server | Set the server's log level |
notifications/message | Server → Client | Server sends a log message |
completion/complete | Client → Server | Auto-complete a prompt argument or a resource template argument (a parameterised resource URI such as users://{id}) |
The Initialize Handshake in Detail
The initialize exchange is the most important message pair in MCP. Everything else depends on it succeeding correctly.
Client → Server (initialize request):
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {
"roots": { "listChanged": true },
"sampling": {},
"elicitation": {}
},
"clientInfo": {
"name": "Cursor",
"version": "0.43.0"
}
}
}
Server → Client (initialize response):
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true, "listChanged": true },
"prompts": { "listChanged": true },
"logging": {}
},
"serverInfo": {
"name": "my-database-server",
"version": "1.0.0"
}
}
}
Client → Server (initialized notification):
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
After this three-way exchange, the client knows exactly what the server supports. If the server advertises tools.listChanged: true, the client knows the server may push notifications/tools/list_changed and should refresh its tool list when one arrives (no subscribe request is needed). If resources.subscribe is not in the capabilities, the client knows not to send resources/subscribe requests.
A Full Tool Call Trace
Here's a complete trace of an AI calling the search_products tool:
1. CLIENT: tools/list request
→ Server returns: [{ name: "search_products", ... }]
2. Client passes tool definition to the AI model in its context window
3. User asks: "Find me an ergonomic keyboard under $200"
4. AI model decides to call search_products and produces a tool-call output
5. CLIENT → SERVER: tools/call
{
"method": "tools/call",
"params": {
"name": "search_products",
"arguments": { "query": "ergonomic keyboard", "max_price": 200 }
}
}
6. SERVER executes: queries database, filters by price
7. SERVER → CLIENT: tools/call response
{
"result": {
"content": [{ "type": "text", "text": "[{...product data...}]" }],
"isError": false
}
}
8. Client passes result back to AI model
9. AI generates natural language response to the user
The round-trip is complete. The model never communicated with the server directly; the MCP client mediated the entire exchange.
Error Handling
JSON-RPC errors and MCP errors are distinct:
JSON-RPC errors indicate a protocol-level failure, invalid method, malformed message, server can't process the request at all. They appear in the error field of the response:
{
"jsonrpc": "2.0",
"id": 42,
"error": {
"code": -32601,
"message": "Method not found",
"data": { "method": "tools/doesntexist" }
}
}
MCP tool errors indicate that the tool executed but encountered an application-level error. These are returned in the result with isError: true:
{
"jsonrpc": "2.0",
"id": 42,
"result": {
"content": [{ "type": "text", "text": "Database connection failed: timeout after 30s" }],
"isError": true
}
}
The distinction matters: isError: true in a result means the AI should be told about the failure and can reason about it (retry? fallback? ask the user?). A JSON-RPC error means something broke at the protocol level and should be handled by the client infrastructure.
Key Takeaways
- MCP messages are JSON-RPC 2.0: requests (with id), responses (result or error), and notifications (no id)
- Methods are grouped by feature area: tools/, resources/, prompts/*, lifecycle
- The
initializehandshake negotiates protocol version and capabilities - Tool execution errors (isError: true) are different from protocol errors (JSON-RPC error field)
- Every message flows through the MCP client; the model never touches the protocol directly
In the next module, we examine how these messages physically travel between client and server: the transport layer.