Skip to main content

Class 4: The Protocol Layer

Duration: ~40 minutes | Level: Intermediate | Prerequisites: Class 3: Tools, Resources, and Prompts

Which revision this class describes

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/setLevel and notifications/roots/list_changed, and replaced resources/subscribe and resources/unsubscribe with subscriptions/listen.
  • Every result carries a resultType of "complete" or "input_required", with an absent value read as "complete", and the rule on reusing a request id was narrowed.
  • Every request carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in params._meta, and one that omits either is rejected with -32602.
  • It reserved -32020 to -32099 for spec-defined error codes, renumbered resource-not-found from -32002 to -32602, and made ttlMs and cacheScope required 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 ruleWhere it comes from
A string or an integerMCP, narrowing what base JSON-RPC allows
Never nullMCP. 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 own2025-11-25
Never the id of a request that is still waiting for a response2026-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:

MethodDirectionPurpose
initializeClient → ServerStart a session, negotiate version + capabilities
notifications/initializedClient → ServerAcknowledge server's initialize response (notification)
pingEither directionCheck 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:

MethodDirectionPurpose
server/discoverClient → ServerReport 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 settledLegacy (2025-11-25)Modern (2026-07-28)
Protocol versioninitialize params.protocolVersion, once per connectionio.modelcontextprotocol/protocolVersion in _meta, on every request (required)
Client capabilitiesinitialize params.capabilitiesio.modelcontextprotocol/clientCapabilities in _meta (required)
Client identityinitialize params.clientInfoio.modelcontextprotocol/clientInfo in _meta (SHOULD)
Server capabilities and identitythe initialize resultserver/discover, and io.modelcontextprotocol/serverInfo in the _meta of each result
Client is readynotifications/initializedthe client begins sending requests straight away
Liveness checkpingthe transport, and the client re-issues a lost request with a new id
Log levellogging/setLevelio.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

MethodDirectionPurpose
tools/listClient → ServerDiscover available tools
tools/callClient → ServerExecute a specific tool
notifications/tools/list_changedServer → ClientNotify that tool list has changed

Resource Methods

MethodDirectionPurpose
resources/listClient → ServerDiscover available resources
resources/templates/listClient → ServerDiscover 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/readClient → ServerFetch resource content
resources/subscribeClient → ServerSubscribe to change notifications for one resource
resources/unsubscribeClient → ServerCancel that subscription
notifications/resources/updatedServer → ClientNotify that a resource changed
notifications/resources/list_changedServer → ClientNotify that the resource list changed

Prompt Methods

MethodDirectionPurpose
prompts/listClient → ServerDiscover available prompts
prompts/getClient → ServerFetch a specific prompt (with arguments)
notifications/prompts/list_changedServer → ClientNotify that prompt list changed

Utility Methods

MethodDirectionPurpose
logging/setLevelClient → ServerSet the server's log level
notifications/messageServer → ClientServer sends a log message
completion/completeClient → ServerAuto-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 ruleWhat it means, and what breaks without it
Pagination covers tools/list, resources/list, resources/templates/list and prompts/listThe methods that fetch one thing, tools/call, resources/read and prompts/get, return it whole
The page size belongs to the serverClients MUST NOT assume a fixed one, so a client that reads fifty tools and stops has guessed
The cursor is opaqueClients 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-28It 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"):

FieldWhat the server is sayingWhat the client doesWhen it is absent
ttlMshow long this result may be treated as fresh, in millisecondsreuses 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 backofftreat 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 valuea 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 cachea 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 errorTool execution error
Where it appearsthe error field of the responsethe result field, with isError: true
What it meansthe 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 itthe client's own code. A client MAY hand it to the model as well, though it rarely helps the model recoverthe model, which SHOULD receive it so it can reason about the failure and correct itself
What can be donefix the call, or show the failure to the userchange 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-25 the initialize handshake negotiates protocol version and capabilities; 2026-07-28 carries both in _meta on 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 */list method is paginated by an opaque cursor; follow nextCursor until it is absent, and treat an empty string as a real cursor
  • Under 2026-07-28 an absent ttlMs means 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 -32700 to -32603.
  • Base Protocol (2026-07-28): the current envelope, with the required resultType, the per-request _meta fields, statelessness, and the reserved error-code range.
  • Lifecycle (2025-11-25): the normative rules behind the initialize walkthrough, 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): ttlMs and cacheScope in 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