Class 4: The Protocol Layer
Duration: ~40 minutes | Level: Intermediate | Prerequisites: Class 3: Tools, Resources, and Prompts
The three message shapes below, request, response and notification, are the same in 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,logging/setLevelandnotifications/roots/list_changed, and replacedresources/subscribeandresources/unsubscribewithsubscriptions/listen. - Every result carries a
resultTypeof"complete"or"input_required", with an absent value read as"complete", and the rule on reusing a requestidwas narrowed. - Every request carries
io.modelcontextprotocol/protocolVersionandio.modelcontextprotocol/clientCapabilitiesinparams._meta, and one that omits either is rejected with-32602. - It reserved
-32020to-32099for spec-defined error codes, renumbered resource-not-found from-32002to-32602, and madettlMsandcacheScoperequired on list results.
The sections below flag the changes where they matter, and Class 8 has the full list.
JSON-RPC 2.0: The Envelope
MCP messages are encoded as JSON-RPC 2.0, an RPC specification that predates MCP. RPC is short for remote procedure call: one process calls a function that runs inside another process. The choice was deliberate: JSON-RPC is language-agnostic, text-based, and has implementations in every ecosystem.
Its envelope, the outer wrapper every message shares, comes in three shapes, all defined by JSON-RPC 2.0.
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 that omits the id is a notification rather than a request, a convention MCP inherits from base JSON-RPC unchanged. What MCP tightens is the id itself:
| The rule | Where it comes from |
|---|---|
| A string or an integer | MCP, narrowing what base JSON-RPC allows |
Never null | MCP. Base JSON-RPC allows null but discourages it, because null is reserved for responses to requests whose id couldn't be read |
| Never one the sender used before in this session, and client and server each track their own | 2025-11-25 |
Never the id of a request that is still waiting for a response | 2026-07-28, which narrowed the session rule to this, because a protocol without sessions cannot check more |
Clients commonly satisfy these 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 do not carry an id. 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. 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 | Check that the other side is still there, and measure the round trip. The spec files it under utilities, and it is the one request either side may send before the handshake finishes |
Modern (2026-07-28). A modern session does not open with a handshake. All three methods above were removed by SEP-2575, a Specification Enhancement Proposal: the numbered document that carries a change into the spec. None of them appears in the 2026-07-28 schema, the file that defines every message the protocol allows. None is merely deprecated either, unlike Roots, Sampling and Logging, which keep a minimum twelve-month window before removal, and that floor drops to ninety days only for an active security risk. 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 |
What the handshake settled once per connection now travels on every request:
| What has to be settled | Legacy (2025-11-25) | Modern (2026-07-28) |
|---|---|---|
| Protocol version | initialize params.protocolVersion, once per connection | io.modelcontextprotocol/protocolVersion in _meta, on every request (required) |
| Client capabilities | initialize params.capabilities | io.modelcontextprotocol/clientCapabilities in _meta (required) |
| Client identity | initialize params.clientInfo | io.modelcontextprotocol/clientInfo in _meta (SHOULD) |
| Server capabilities and identity | the initialize result | server/discover, and io.modelcontextprotocol/serverInfo in the _meta of each result |
| Client is ready | notifications/initialized | the client begins sending requests straight away |
| Liveness check | ping | the transport, and the client re-issues a lost request with a new id |
| Log level | logging/setLevel | io.modelcontextprotocol/logLevel in _meta, per request |
A modern request carries its own context in params._meta:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": { "name": "Cursor", "version": "0.43.0" }
}
}
}
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. ping went with it. On Streamable HTTP, the HTTP transport we cover in Class 5, a broken stream loses the in-flight request, the one the server has not answered yet, and the client re-issues it with a new id. Liveness became the transport's concern.
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/templates/list | Client → Server | Discover resource templates, the parameterised URI patterns such as users://{id}. A separate request from resources/list, so a client must call it to see them |
resources/read | Client → Server | Fetch resource content |
resources/subscribe | Client → Server | Subscribe to change notifications for one resource |
resources/unsubscribe | Client → Server | Cancel that subscription |
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}) |
Every List Is Paginated
The four */list methods above can hold more entries than fit comfortably in one response, so the protocol pages them. A client that ignores paging appears to work perfectly. It reads the first page and treats it as the complete set, so the model is offered a subset of the server's tools, and no error is raised to say so.
The mechanism is a cursor. The server returns a page and includes a nextCursor string when more results exist:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [ "...the first page..." ],
"nextCursor": "eyJwYWdlIjogM30="
}
}
The client asks for the next page by sending that string straight back as a cursor parameter:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/list",
"params": { "cursor": "eyJwYWdlIjogM30=" }
}
The client repeats that request until a result comes back without a nextCursor field:
The client checks whether the nextCursor field arrived, and does not read anything into its value. Four rules govern the cursor, and the last three are where implementations go wrong:
| The rule | What it means, and what breaks without it |
|---|---|
Pagination covers tools/list, resources/list, resources/templates/list and prompts/list | The methods that fetch one thing, tools/call, resources/read and prompts/get, return it whole |
| The page size belongs to the server | Clients MUST NOT assume a fixed one, so a client that reads fifty tools and stops has guessed |
| The cursor is opaque | Clients MUST NOT parse it, modify it, or draw any conclusion from its contents beyond whether a value arrived at all. The base64 string above, a way of writing arbitrary bytes as plain text, happens to decode to JSON, and relying on that is precisely what the rule forbids |
An empty string is a valid cursor, under 2026-07-28 | It MUST NOT be read as the end of results, and only an absent nextCursor means the end. A client that treats an empty string as "no cursor", which is what if (cursor) does in JavaScript and isEmpty() does in Java, silently discards the rest of the list |
Revision 2025-11-25 adds one rule that 2026-07-28 dropped along with sessions: clients MUST NOT persist a cursor across sessions. An invalid cursor SHOULD come back as -32602 (Invalid params).
Freshness: ttlMs and cacheScope
Revision 2026-07-28 added two fields to those same results, and requires them on server/discover, tools/list, prompts/list, resources/list, resources/templates/list and resources/read when the result is complete (resultType: "complete"):
| Field | What the server is saying | What the client does | When it is absent |
|---|---|---|---|
ttlMs | how long this result may be treated as fresh, in milliseconds | reuses the cached result for that long, then fetches again the next time it needs the data. It SHOULD NOT refetch in the background when the TTL expires, and an implementation that does poll applies jitter and backoff | treat it as 0, immediately stale, because caching it indefinitely would keep an old list in front of the model |
cacheScope | "public": the result does not contain user-specific data. "private": it does. Every page of one list request carries the same value | a public result may be stored and served to any user by any client, gateway or caching proxy. A private one may be reused only within the same authorization context, so a different access token needs a different cache | a 2025-11-25 server does not send it |
cacheScope is a caching hint, and access control still has to be applied per primitive on every call. A server that filters tools/list per user must mark that result "private", because a "public" result may be served to any caller even when it came from an authenticated endpoint.
A paged tools/list result carrying both fields looks like this:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"tools": [ "...the first page..." ],
"nextCursor": "eyJwYWdlIjogM30=",
"ttlMs": 60000,
"cacheScope": "private"
}
}
Each page is cached on its own ttlMs, and pages are not guaranteed to agree with each other: if the data changes between two fetches, a client may see duplicates or gaps. A client that needs a consistent snapshot starts again from the beginning, omitting the cursor.
The two work alongside the listChanged notifications instead of replacing them. A notification says something has changed now; ttlMs says how long the answer stays good while nothing changes.
Servers on that revision SHOULD also return tools from tools/list in a deterministic order, the same order across requests while the set of tools does not change. A client can then cache the list, and the model's prompt cache keeps hitting, because the provider reuses the unchanged front of a prompt.
The Initialize Handshake in Detail
The initialize exchange is the legacy handshake that opens every 2025-11-25 session, including every session the Java MCP SDK 2.0.0 opens. Everything else in the session depends on it succeeding. It is three messages:
The third message does not carry an id, so the server does not answer it and the client does not wait.
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 exchange the client knows 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 is a complete trace of a model calling the search_products tool:
Every message between the model and the server passes through the client. The tool result enters the model's context as text from another party, so a client validates it first, and Class 7 works through what an attacker can put there.
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 decides who reads the failure:
| JSON-RPC error | Tool execution error | |
|---|---|---|
| Where it appears | the error field of the response | the result field, with isError: true |
| What it means | the request could not be processed: unknown method (-32601), invalid params (-32602), malformed JSON (-32700), internal failure (-32603) | the tool ran and reported a problem: an API failure, a value out of range, a business rule |
| Who reads it | the client's own code. A client MAY hand it to the model as well, though it rarely helps the model recover | the model, which SHOULD receive it so it can reason about the failure and correct itself |
| What can be done | fix the call, or show the failure to the user | change the arguments and call again |
The model reads the text inside a tool execution error, and the user usually sees it in the transcript. Keep it to what happened and what to do next. OWASP files leaking stack traces, hostnames or connection strings through a model under Sensitive Information Disclosure.
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
- Under
2025-11-25theinitializehandshake negotiates protocol version and capabilities;2026-07-28carries both in_metaon every request - Tool execution errors (isError: true) are different from protocol errors (JSON-RPC error field)
- Every message flows through the MCP client, the only party that touches the protocol directly
- Every
*/listmethod is paginated by an opaque cursor; follownextCursoruntil it is absent, and treat an empty string as a real cursor - Under
2026-07-28an absentttlMsmeans stale, and a list that varies per user must be"private"
In the next class, we examine how these messages physically travel between client and server: the transport layer.
Further Reading
- JSON-RPC 2.0 Specification: the whole envelope in one page, including the id rules MCP tightens and the standard error codes from
-32700to-32603. - Base Protocol (2026-07-28): the current envelope, with the required
resultType, the per-request_metafields, statelessness, and the reserved error-code range. - Lifecycle (2025-11-25): the normative rules behind the
initializewalkthrough, including what each side may send before the handshake finishes. - Versioning and Compatibility (2026-07-28): the legacy and modern definitions, and the compatibility matrix for every combination of client and server era.
- Pagination (2026-07-28): the cursor rules in their normative form, including the empty-string cursor.
- Caching (2026-07-28):
ttlMsandcacheScopein full, with the freshness calculation and the rules for a public scope. - Tools (2026-07-28): the two error mechanisms written normatively, plus the deterministic-ordering rule for
tools/list. - Key Changes (2026-07-28): every change named in the revision box above, each with the SEP that carried it.
Sources
- JSON-RPC 2.0 Specification: base JSON-RPC permits but discourages a
nullid, becausenullis used for responses to an unknown id. - Base Protocol (2025-11-25): the id must be a string or integer, must not be
null, and must not have been used before by the requestor in the same session. - Base Protocol (2026-07-28): the required
resultType, and the id rule narrowed to requests still awaiting a response. It also carries the_metarequirements with-32602on omission, the reserved-32020to-32099range, and the statelessness rule quoted in this class. - Key Changes (2026-07-28): SEP-2575 removed the lifecycle methods and replaced
resources/subscribeandresources/unsubscribewithsubscriptions/listen. Resource-not-found moved from-32002to-32602, andttlMsandcacheScopebecame required. - Versioning and Compatibility (2026-07-28): the specification's own definitions of legacy and modern.
- Discovery: server/discover (2026-07-28): the method reports supported versions, capabilities and identity, and servers MUST implement it while clients may skip it.
- Pagination (2026-07-28): the four paginated operations, the server-owned page size, the opacity rules, the empty string as a valid cursor, and
-32602for an invalid one. - Pagination (2025-11-25): the rule that cursors must not be persisted across sessions.
- Caching (2026-07-28): which operations must carry the two fields, what public and private mean for an authorization context, the absent
ttlMsread as0, TTL not being a polling interval, and the lack of cross-page consistency. - Tools (2026-07-28): clients SHOULD pass tool execution errors to the model and MAY pass protocol errors, and the SHOULD on deterministic
tools/listordering. - Feature Lifecycle and Deprecation Policy: the twelve-month deprecation window is a minimum, and it can be shortened to ninety days for an active security risk.
- Release v2.0.0, modelcontextprotocol/java-sdk: the Java MCP SDK
2.0.0tracks the2025-11-25specification. - LLM02:2025 Sensitive Information Disclosure (OWASP Top 10 for LLM Applications): error text handed to a model can leak internal detail.