MCP Protocol Basics - JSON-RPC and Stateless Design
This document explains the MCP wire format and how the stateless base protocol introduced in 2026-07-28 works. The big picture is covered in Introduction to MCP - What Is the Model Context Protocol; here we look at what goes into each message at the JSON-RPC level. For changes between versions and migration, see MCP 2026-07-28 Spec Changes and Migration. For transport-specific topics, see Transports.
1. What This Document Covers
- JSON-RPC 2.0 basics
- Stateless design
- Information carried in
_meta - server/discover
- resultType
- Capabilities and extensions
- Error code allocation
- Versioning and compatibility
- Removed utilities
JSON-RPC 2.0 Basics
All MCP messages follow JSON-RPC 2.0 and are UTF-8 encoded. There are three kinds.
| Kind | Sender | Required fields | Notes |
|---|---|---|---|
| Request | Client | jsonrpc, id, method, (optional params) | id is a string or integer. Unlike base JSON-RPC, null is not allowed. It must not duplicate the ID of another unanswered request |
| Response (result) | Server | jsonrpc, id, result | result must contain resultType |
| Response (error) | Server | jsonrpc, id, error (code, message, optional data) | code is an integer. id may be omitted only when the request could not be read |
| Notification | Either direction | jsonrpc, method, (optional params) | Has no id. The receiver does not respond |
In 2026-07-28, the server never sends its own JSON-RPC requests to the client. When the server needs input from the client, it embeds the input request in a result (Multi Round-Trip Requests; see Elicitation and Multi Round-Trip Requests). The client does not send JSON-RPC responses either. Message flow is narrowed to "the server responds to a client request" and "notifications".
Stateless Design
MCP is now a stateless protocol. All the information needed to process a request is contained in the request itself. The server processes each request independently and must not infer state from previous requests, even on the same connection or stream.
- The
initializeandnotifications/initializedhandshake is removed. You can send any request right after opening a connection - The server must not fill in protocol version, capabilities, or client identity from earlier requests. It reads all of them from the
_metaof that request - An open connection (for example, a stdio process) is neither a conversation nor a session. Clients may interleave unrelated requests on the same transport. The server must not use process or connection identity as a substitute for conversation continuity
- State that spans multiple requests (long-running tasks, application-level handles, and so on) is referenced by an explicit identifier the client passes each time
A long-lived request such as subscriptions/listen is still a request and response at the protocol level. The response is just a stream that keeps delivering notifications, and its state is scoped to the request itself, not to the connection.
A "stateless protocol" does not mean a "stateless application". If your application needs state, design it so the server issues a handle and the client passes it as a tool argument. See Introduction to MCP - What Is the Model Context Protocol for details.
Information Carried in _meta
_meta is the field clients and servers use to attach metadata to messages. A key has the form "optional prefix + / + name". The prefix is a series of dot-separated labels, and reverse DNS notation (com.example/) is recommended. A prefix whose second label is modelcontextprotocol or mcp is reserved for MCP. For example, io.modelcontextprotocol/ and dev.mcp/ are reserved, but com.example.mcp/ is not.
The keys reserved by the specification are as follows.
| Key | Where | Required | Content |
|---|---|---|---|
io.modelcontextprotocol/protocolVersion | Request | Required | Protocol version of that request (e.g. "2026-07-28") |
io.modelcontextprotocol/clientCapabilities | Request | Required | Client capabilities relevant to that request |
io.modelcontextprotocol/clientInfo | Request | Optional (recommended to send) | Client name and version |
io.modelcontextprotocol/serverInfo | Result | Optional (recommended to include) | Server name and version |
io.modelcontextprotocol/logLevel | Request | Optional | Minimum log level the server may emit |
io.modelcontextprotocol/subscriptionId | Notifications from subscriptions/listen | Required | Ties a notification to its originating subscription request |
progressToken | Request | Optional | Opts the request into progress notifications |
traceparent, tracestate, baggage | Either direction | Optional | OpenTelemetry trace context. Values follow the W3C Trace Context / Baggage formats. An exception to the prefix rule |
A request missing a required field is malformed. The server rejects it with the JSON-RPC error code -32602 (Invalid params), and on HTTP returns 400 Bad Request. The server also must not rely on capabilities the client has not declared. If processing requires such a capability, it returns MissingRequiredClientCapabilityError (-32021) and lists the missing capabilities in data.requiredCapabilities.
clientInfo and serverInfo are self-reported by the sender and are not verified by the protocol. Use them for display, logging, and debugging, not for switching behavior or for security decisions.
server/discover
server/discover returns the server's supported protocol versions, capabilities, and identity in a single request. The server must implement it, and whether the client calls it is optional. The result carries the cache fields (ttlMs, cacheScope).
Calling it is worthwhile mainly in two situations.
- Presenting server information: you want to show what the server supports in one call instead of calling
tools/list,prompts/list, andresources/listseparately - stdio backward-compatibility probe: on stdio, which has no status codes like HTTP, tell whether the server is old or new (described below)
You do not need to call it every time as connection setup. A client can send any RPC directly and, if the version does not match, receive UnsupportedProtocolVersionError and retry.
resultType
Every result has a resultType. The client uses this value to decide how to interpret result.
| Value | Meaning |
|---|---|
complete | The request is complete and result holds the final content |
input_required | More input is needed to proceed. result is an InputRequiredResult, and inputRequests carries the required input |
Clients must treat a result without resultType as complete, because servers on 2025-11-25 or earlier do not return this field. On the other hand, a value the client does not know is treated as invalid. Extensions can add resultType values, but only values from extensions advertised through capabilities are valid. The client's retry procedure on receiving input_required is covered in Elicitation and Multi Round-Trip Requests.
Capabilities and Extensions
Capabilities are declared with ClientCapabilities and ServerCapabilities. The client sends its own in each request as clientCapabilities in _meta. The server's are included in the server/discover result.
Optional features outside the core protocol are negotiated as extensions. The extensions field in capabilities is a map from extension identifier to that extension's own settings object. Identifiers follow the same rules as _meta keys, and a prefix is mandatory. If no settings are needed, an empty object indicates support.
{
"capabilities": {
"tools": {},
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
If only one party supports an extension, the supporting party must either revert to core protocol behavior or reject the request with an appropriate error. Extensions are recommended to document their fallback behavior. For the list of extensions and how to use them, see MCP Extensions - Tasks, MCP Apps, and Skills.
Error Code Allocation
For general protocol failures, MCP uses the standard JSON-RPC codes (-32700, -32600 to -32603). The range -32000 to -32099, which JSON-RPC reserves for implementation-defined errors, is partitioned as follows.
| Range | Treatment |
|---|---|
-32000 to -32019 | Codes implementations used in the past (legacy). Allocating new codes is prohibited, and new implementations are recommended not to use them. Apart from -32002, receivers must not assume any specific meaning |
-32020 to -32099 | Reserved for the MCP specification. Only codes defined by the specification are used, with their defined meanings |
2026-07-28 defines three codes.
| Code | Name | Use |
|---|---|---|
-32020 | HeaderMismatch | HTTP headers do not match the body, or required headers are missing |
-32021 | MissingRequiredClientCapability | A client capability required for processing was not declared |
-32022 | UnsupportedProtocolVersion | The server does not support the requested version |
Of the codes from earlier versions, implementations of this version must not emit these two.
-32002: resource not found (2025-11-25 and earlier). Replaced by-32602(Invalid params). Clients are recommended to keep accepting-32002from servers on earlier versions-32042: URL elicitation required (2025-11-25 only)
If you allocate your own codes for application-specific errors, use values outside the JSON-RPC reserved range (-32768 to -32000).
Versioning and Compatibility
There is no negotiation handshake. Every request declares its own version, and the server decides per request whether to accept or reject it. On HTTP, the same value is also carried in the MCP-Protocol-Version header.
If the server does not support the requested version (whether unknown or known but not supported), it returns UnsupportedProtocolVersionError and lists the versions it supports. The client picks a common version from the list and retries, or reports an error to the user if there is none.
The specification names the eras of versions as follows.
- Modern: versions that carry version, identity, and capabilities in per-request
_meta(2026-07-28 and later) - Legacy: versions that create a session with the
initializehandshake (2025-11-25 and earlier) - Dual-era: implementations that support both
The outcome for each combination is as follows.
| Client | Server | Outcome |
|---|---|---|
| Modern | Modern | Works. server/discover is optional |
| Modern | Legacy | Fails. The server may return its own error, stay silent, or process an era-ambiguous method under legacy semantics. On stdio, sending server/discover first is recommended so that it fails deterministically |
| Dual-era | Modern | Works. The client stays modern |
| Dual-era | Legacy | Works. If a modern request fails without a recognized modern error, the client switches to initialize |
| Legacy | Modern | Fails. A legacy client has no way to move to the newer version |
| Legacy | Dual-era | Works. The server answers initialize and runs on the negotiated older version |
A dual-era server treats a request with _meta as modern and one that starts with initialize as legacy. It can serve both at the same time on the same endpoint or process.
Era determination is a property of the server, not of each request. Clients are recommended to cache the result for the lifetime of the server process (stdio) or origin (HTTP), and to probe again when the assumption fails.
How to tell the era differs by transport.
- stdio: send
server/discoverbefore any other request. If aDiscoverResultcomes back, the server is modern. If a recognized modern error such asUnsupportedProtocolVersionErrorcomes back, the server is modern but the version does not match, so retry with a supported version. For any other error or a timeout, treat it as legacy and switch toinitialize. The switch must not be tied to one specific error code - Streamable HTTP: send a modern request first, and on
400 Bad Requestinspect the body. If it contains a recognized modern JSON-RPC error, the server is modern. If the body is empty or anything else, switch toinitialize
A server that supports only modern versions is recommended to name its supported versions in the error it returns to initialize, because for a legacy client that message may be the only diagnostic it can show the user.
Removed Utilities
2026-07-28 removed the following utilities.
| Removed | Instead |
|---|---|
ping | No successor method is defined |
logging/setLevel | Specify per request with io.modelcontextprotocol/logLevel in _meta. The server must not emit notifications/message for a request without this field |
notifications/roots/list_changed | Roots input is obtained through MRTR when needed |
This is separate from the deprecation of Roots / Sampling / Logging. Deprecated features keep working during the migration window, whereas the methods listed here have been removed from the specification. For the list of deprecations, see MCP 2026-07-28 Spec Changes and Migration.
2. Foundation Differences Between 2025-11-25 and 2026-07-28
| Aspect | Up to 2025-11-25 | 2026-07-28 |
|---|---|---|
| Starting a connection | initialize / initialized handshake | No handshake. server/discover is optional |
| Sessions | State kept via Mcp-Session-Id | No protocol session. State is managed with explicit handles |
| Version and capabilities | Negotiated once at initialization | Included in _meta of every request |
| List results | May vary per connection | Do not vary per connection. Return ttlMs and cacheScope |
| Server-initiated requests | Sent directly from server to client | Replaced by Multi Round-Trip Requests |
3. Common Pitfalls for Developers
Implementing the Handshake from Old Material
Material and SDK samples from 2025 assume you call initialize first. If you send initialize to a 2026-07-28 server, it is rejected as an unknown method. Nor do you need to write code that handles initialize. A modern server processes every request from _meta alone. If you also want to accept legacy clients, implement both as a dual-era server.
Forgetting the Required _meta Fields
When debugging with a bare HTTP client or curl, leaving protocolVersion and clientCapabilities out of _meta gets the request rejected with -32602 and 400 Bad Request. Even a request that uses no capabilities needs the empty object {}. On HTTP, a mismatch between the body value and the MCP-Protocol-Version header is also rejected with HeaderMismatch (-32020).
Branching Behavior or Authorization on clientInfo
clientInfo and serverInfo are self-reported and unverified. If you switch authorization or feature exposure by client name, it is bypassed just by spoofing the name. Do authorization with the tokens described in MCP Authorization - OAuth 2.1 and Metadata Discovery.
Reading result Blindly Without Checking resultType
If you assume the result of tools/call and similar calls is complete, then when input_required comes back it looks like an empty result with no content. Check resultType before reading the result, and go to the MRTR procedure on input_required. Conversely, servers on older versions do not return resultType, so treat a missing value as complete.
The Error Code for Resource Not Found
The code for a missing resource changed from -32002 to -32602. Clients need to treat both old and new as "not found". Servers must not return -32002.
Making the Connection or Process the Conversation Boundary
Designs that restart a stdio process per conversation, or that assume "this process belongs to one conversation" and keep context in memory, do not fit the specification. Unrelated requests can be mixed in one process. Carry state with explicit handles.
Relying on ping for Liveness
ping has been removed. There is no successor method for liveness checks. Design for detecting problems through real request timeouts and stream disconnects. When a stream connection drops, reissue the request with a new request ID (see Transports).
Allocating Your Own Error Codes in the -32000 Range
New allocations in -32000 to -32019 are prohibited, and -32020 to -32099 is reserved by the specification. Allocate application-specific codes outside -32768 to -32000.
4. Code Examples
Minimal Request and Response
A tools/call example. Note the required _meta, and the resultType and serverInfo in the result.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "Tokyo" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "Sunny, 22 degrees" }],
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "ExampleServer", "version": "1.0.0" }
}
}
}
server/discover
{
"jsonrpc": "2.0",
"id": "discover-1",
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
{
"jsonrpc": "2.0",
"id": "discover-1",
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": {}, "resources": {} },
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "ExampleServer", "version": "1.0.0" }
},
"instructions": "This server provides weather and resource utilities.",
"ttlMs": 3600000,
"cacheScope": "public"
}
}
Version Mismatch Error
The client picks a version it supports from supported and retries. On HTTP this is 400 Bad Request.
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": {
"supported": ["2026-07-28", "2025-11-25"],
"requested": "1900-01-01"
}
}
}
Missing Capability
The shape of the error when a required capability is not included in the request (data.requiredCapabilities lists the missing ones).
{
"jsonrpc": "2.0",
"id": 2,
"error": {
"code": -32021,
"message": "Missing required client capability",
"data": { "requiredCapabilities": { "elicitation": {} } }
}
}
For the exact schema of data, check MissingRequiredClientCapabilityError in the Schema Reference.
Reading a Result (TypeScript)
A client-side skeleton that checks resultType before handling the result.
type Result = { resultType?: string; [key: string]: unknown };
function readResult(result: Result): Result {
// Servers on older versions do not return resultType. Treat a missing value as complete
const type = result.resultType ?? "complete";
switch (type) {
case "complete":
return result;
case "input_required":
// Handle inputRequests, attach inputResponses, and retry the original request
throw new Error("MRTR: input required");
default:
// Treat an unknown value as invalid
throw new Error(`Invalid resultType: ${type}`);
}
}
5. Summary
- MCP messages are JSON-RPC 2.0. The server responds to client requests, and the server does not send its own requests
- 2026-07-28 is stateless. It removes
initializeand sessions, and carriesprotocolVersionandclientCapabilitiesin the_metaof every request server/discovermust be implemented by servers, and clients call it optionally. On stdio it serves as the backward-compatibility probe- Every result has a
resultType. A missing value meanscomplete, andinput_requiredleads to MRTR - For error codes,
-32020to-32099is reserved for the specification. Resource not found is-32602 - A version mismatch is detected with
UnsupportedProtocolVersionError, and the client picks fromsupportedand retries ping,logging/setLevel, andnotifications/roots/list_changedwere removed
Related Documents
- Introduction to MCP - What Is the Model Context Protocol
- MCP 2026-07-28 Spec Changes and Migration
- Transports - stdio and Streamable HTTP
- Elicitation and Multi Round-Trip Requests