JSON-RPC Messages

By the Syncfusion DevTutorials team · Last updated

Every field of MCP requests, responses and notifications, id matching, the core methods, _meta and resultType, error codes, tool errors versus protocol errors, and why servers are now stateless.

When you fill in a form at a bank, each box has a set place: your name here, your account number there, and your signature at the bottom. The clerk knows where to find each detail without searching through the whole form.

MCP messages work the same way. Each field has a specific place and purpose. Once you know those fields, you can read a message and understand what it asks or what answer it carries.

The main idea: an MCP message is a JSON object with a defined structure. A request carries a method, an id, and params. A response carries the same id and either a result or an error. A notification carries a method but no id.

To see why these fields matter, let's follow a developer as they diagnose a failed request.

A Real-World Example

Brightgrid's assistant is slow on Monday mornings. Ravi, a developer, opens the Inspector and sees this:

  • Request id 4: tools/call for lookup_order.
  • Response for id 4: an error with code -32602.

Because the response has the same id, Ravi can immediately see that the error belongs to his request. The code means "invalid params." Ravi checks the error message and sees that the model sent order_id as a number instead of text. He improves the tool description so the model is less likely to make the same mistake.

In simple words: message fields and error codes help Ravi move from "something broke" to a specific problem he can investigate.

What You Will Learn

In this lesson, you will learn:

  1. The fields of requests, responses and notifications.
  2. How ids match answers to questions.
  3. The core methods and what they return.
  4. What _meta and resultType are for.
  5. The error codes, and how a tool error differs from a protocol error.
  6. Why MCP servers are stateless in the 2026-07-28 spec, and why sessions and ping were removed from that version.

The MCP Lab has a Raw request tab. It lets you write an HTTP request by hand and send it straight to the server, with no client in between. It includes ready-made examples, including invalid requests.

The Three Frames

Before looking at real messages in the Lab, it helps to understand the three basic message shapes used throughout MCP.

The three message shapes. A response carries the same id as its request. A notification has no id, so nobody answers it.

Request Frame

A request asks the server to do something. It has four main fields:

FieldMeaning
jsonrpcThe JSON-RPC version. For MCP messages, it is "2.0"
idA number or text the client chooses to match this request with its response. Each open request needs a different id
methodThe operation to perform, such as "tools/call"
paramsThe information the method needs, plus request metadata in _meta

Here is a small request builder. It gives each request a different id and adds the metadata required by this course's current protocol version.

A request builder

// Build any MCP request. Every request in 2026-07-28 carries the same _meta.
let nextId = 1;
function makeRequest(method, params = {}) {
  return {
    jsonrpc: "2.0",
    id: nextId++,
    method,
    params: {
      ...params,
      _meta: {
        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
        "io.modelcontextprotocol/clientInfo": { name: "my-host", version: "1.0.0" },
        "io.modelcontextprotocol/clientCapabilities": {},
      },
    },
  };
}

const a = makeRequest("tools/list");
const b = makeRequest("tools/call", {
  name: "lookup_order",
  arguments: { order_id: "A-1001" },
});
console.log(a.id, a.method, Object.keys(a.params).join(","));
console.log(b.id, b.method, Object.keys(b.params).join(","));

Expected output:

1 tools/list _meta
2 tools/call name,arguments,_meta

Response Frame

A response answers one request. It has jsonrpc, the same id as the request, and exactly one of these:

  • result: an object with the answer. In this spec version, it includes a resultType.
  • error: an object with a numeric code, a message, and sometimes data with more detail.

Notification Frame

A notification sends information without expecting a response. It has jsonrpc and a method, and may have params, but it has no id.

Let's look at a real request in the Lab and identify its fields in the server's reply.

Press Send, then read the reply. Find the id, the result, the content, and the resultType.

The response repeats the request's id. That shared id is how a client matches each answer to the request it belongs to, even if answers arrive out of order.

Id Matching

Why Ids Matter

A client can send several requests before any answer comes back. Answers may arrive in a different order. The id tells the client which request each response answers.

Rules for ids:

  • The client chooses them. They can be numbers or text.
  • An id must not be reused while its request is still waiting for a response.
  • A response with an id the client is not waiting for can be ignored.

Match answers to questions

// Answers can come back in any order. The client matches them by id.
const waiting = new Map([
  [1, "tools/list"],
  [2, "resources/read kb://shipping"],
  [3, "tools/call export_report"],
]);

const arrived = [
  {
    jsonrpc: "2.0",
    id: 2,
    result: {
      contents: [{ uri: "kb://shipping", text: "3 to 6 days" }],
      resultType: "complete",
    },
  },
  { jsonrpc: "2.0", id: 1, result: { tools: [], resultType: "complete" } },
  { jsonrpc: "2.0", id: 9, result: { resultType: "complete" } },
  {
    jsonrpc: "2.0",
    id: 3,
    result: { content: [{ type: "text", text: "Report ready" }], resultType: "complete" },
  },
];

for (const r of arrived) {
  if (!waiting.has(r.id)) {
    console.log(`id ${r.id}: nobody asked for this, ignore it`);
    continue;
  }
  console.log(`id ${r.id}: answer to ${waiting.get(r.id)}`);
  waiting.delete(r.id);
}
console.log("still waiting:", waiting.size);

Expected output:

id 2: answer to resources/read kb://shipping
id 1: answer to tools/list
id 9: nobody asked for this, ignore it
id 3: answer to tools/call export_report
still waiting: 0

Now that you've seen how ids connect requests and responses, let's look at the methods that tell the server what operation to perform.

Core Methods

These are the methods you will use most. Each method names an operation, such as listing tools or reading a resource. The name usually combines an area (tools, resources, or prompts) with an action (list, call, read, or get).

The core methods

// The core methods and what each one returns.
const methods = [
  ["server/discover", "versions, capabilities, instructions"],
  ["tools/list", "tools: name, description, inputSchema"],
  ["tools/call", "content blocks (and isError on failure)"],
  ["resources/list", "resources: uri, name, mimeType"],
  ["resources/read", "contents: uri + text or blob"],
  ["prompts/list", "prompts: name, description, arguments"],
  ["prompts/get", "messages: role + content"],
  ["completion/complete", "completion: values, total, hasMore"],
];

for (const [m, returns] of methods) {
  const [area, verb] = m.split("/");
  console.log(`${area.padEnd(10)} ${verb.padEnd(9)} -> ${returns}`);
}

Expected output:

server     discover  -> versions, capabilities, instructions
tools      list      -> tools: name, description, inputSchema
tools      call      -> content blocks (and isError on failure)
resources  list      -> resources: uri, name, mimeType
resources  read      -> contents: uri + text or blob
prompts    list      -> prompts: name, description, arguments
prompts    get       -> messages: role + content
completion complete  -> completion: values, total, hasMore

completion/complete is a helper that suggests values while the user types an argument, like autocomplete in a search box. It works for prompt arguments and resource templates.

The server suggests A-1001 and A-1002 because both start with A-10.

The method tells the server which operation to perform. Requests also carry metadata about the protocol and the client. That information lives in _meta.

The _meta Field

What It Carries

_meta is a special field inside request params or a result. It holds metadata about the request or result, not the method or answer itself. In 2026-07-28, every request's _meta carries:

  • io.modelcontextprotocol/protocolVersion: the spec version, such as "2026-07-28".
  • io.modelcontextprotocol/clientInfo: the client's name and version.
  • io.modelcontextprotocol/clientCapabilities: features the client supports, such as showing a form to the user.

It may also carry a progressToken. Results carry io.modelcontextprotocol/serverInfo in their _meta.

The io.modelcontextprotocol/ prefix identifies these as MCP metadata keys and helps avoid name collisions.

resultType

A result's resultType tells the client what kind of result it received. Two common values are:

  • "complete": the server has finished and returned an answer.
  • "input_required": the server needs more input. The client collects the answers and sends the request again (MRTR).

A client should check resultType before it reads the rest of the result. Some extensions may define other result types.

List results, like tools/list, also carry ttlMs and cacheScope. They tell the client how long it may keep the list before asking again. You will use them in the caching lesson in Chapter 7.

The message fields tell the client how to read a response. The next question is what to do when that response reports a problem. JSON-RPC uses error codes to describe protocol-level failures.

Error Codes

The Standard Codes

JSON-RPC defines several standard error codes. MCP also defines codes for cases such as HTTP header mismatches and unsupported protocol versions:

Explain an error code

// Error codes you will meet in MCP 2026-07-28.
const codes = {
  "-32700": "Parse error: the body is not valid JSON",
  "-32600": "Invalid request: not a proper JSON-RPC request",
  "-32601": "Method not found",
  "-32602": "Invalid params (also: resource not found, unknown tool)",
  "-32603": "Internal error inside the server",
  "-32020": "HTTP headers and body disagree",
  "-32022": "Unsupported protocol version",
};

function explain(errorReply) {
  const { code, message } = errorReply.error;
  return `${code}: ${codes[code] ?? "a server-specific error"} | server said "${message}"`;
}

console.log(
  explain({
    jsonrpc: "2.0",
    id: 3,
    error: { code: -32601, message: "Method not found" },
  }),
);
console.log(
  explain({
    jsonrpc: "2.0",
    id: 5,
    error: { code: -32022, message: "Unsupported protocol version: 2099-01-01" },
  }),
);
console.log(
  explain({
    jsonrpc: "2.0",
    id: 7,
    error: { code: -32001, message: "Database is busy" },
  }),
);

Expected output:

-32601: Method not found | server said "Method not found"
-32022: Unsupported protocol version | server said "Unsupported protocol version: 2099-01-01"
-32001: a server-specific error | server said "Database is busy"

Try a few invalid requests in the Lab. Each one shows a different kind of protocol error:

Tool Errors vs Protocol Errors

These two kinds of failure are returned differently:

  • A protocol error means the request itself could not be processed, such as when it contains broken JSON or an unknown method. The server sends a JSON-RPC error.
  • A tool error means the request was understood, but the requested work failed: perhaps the order doesn't exist, the database is down, or the arguments didn't match the tool's schema. The server sends a normal result with isError: true and a message in content.

A tool error goes back to the model, which can read the message and may try again. A protocol error usually means the client or host needs to fix the request.

In 2026-07-28, "resource not found" uses code -32602. Older servers used -32002. If you write a client, accept both for a while, because many servers are not updated yet.

Error codes help explain a single request and response. A related design change affects how servers handle every request: the current protocol version is stateless.

Stateless Processing

What Stateless Means

A stateless server does not rely on information saved in a session from an earlier request. Each request carries its own protocol version, client information, capabilities, and the parameters the method needs. That is why _meta appears on every request.

This makes it easier to run several copies of a server behind a load balancer. Any copy can handle a request without first looking up session information stored by another copy.

Every request must stand on its own

// A stateless server: every request must carry everything the server needs.
function check(req) {
  const meta = req.params?._meta ?? {};
  const missing = ["protocolVersion", "clientInfo", "clientCapabilities"].filter(
    (k) => !(`io.modelcontextprotocol/${k}` in meta),
  );
  return missing.length
    ? "rejected, missing " + missing.join(", ")
    : "ok, can be handled by any server copy";
}

const full = {
  method: "tools/list",
  params: {
    _meta: {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { name: "host", version: "1" },
      "io.modelcontextprotocol/clientCapabilities": {},
    },
  },
};
const bare = { method: "tools/list", params: {} };
const oldStyle = {
  method: "tools/list",
  params: {},
  headers: { "Mcp-Session-Id": "abc123" },
};

console.log("full request:       ", check(full));
console.log("bare request:       ", check(bare));
console.log("old session request:", check(oldStyle));

Expected output:

full request:        ok, can be handled by any server copy
bare request:        rejected, missing protocolVersion, clientInfo, clientCapabilities
old session request: rejected, missing protocolVersion, clientInfo, clientCapabilities

Why Sessions Were Removed

Older protocol versions used sessions. The first request, initialize, created a session, and the server sent back an Mcp-Session-Id. Later requests had to include that id and could depend on reaching the same server copy. This made scaling and recovery harder. The 2026-07-28 version removed sessions. The protocol version and client details now travel with each request in _meta.

Why ping Was Removed

Older versions used ping to check whether the other side was still reachable over a long-lived connection. The 2026-07-28 version has no sessions or ping method. A client that wants to check whether a server is responding can send server/discover.

The server answers with -32601, which means the method was not found. In this protocol version, ping is not a method.

Common mistakes

  • Forgetting jsonrpc: "2.0". Without it, the message is not valid JSON-RPC.
  • Mixing up error and isError. error is a protocol error. isError: true is inside a normal result and means the tool failed.
  • Sending _meta only on the first request. Servers are stateless. Every request needs it.
  • Matching answers by order. Always match by id. Answers can arrive in any order.
  • Calling ping or initialize on a server using the 2026-07-28 protocol. Those methods are not part of that version. Use server/discover to check whether the server responds.

The ideas in this lesson fit together: message shape tells you what kind of message you received, ids connect requests to responses, and errors and metadata help explain what happened. Use the quiz to check your understanding.

Exercises

Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.

What to Remember

  • Request: jsonrpc, id, method, params. Response: same id, plus result or error. Notification: method, no id.
  • Match answers by id, never by order.
  • Core methods: server/discover, tools/list and tools/call, resources/list and resources/read, prompts/list and prompts/get, and completion/complete.
  • Every request's _meta carries the version, client info and client capabilities. Every result has a resultType.
  • Protocol errors use JSON-RPC error codes like -32601, -32602, -32020 and -32022. Tool errors are results with isError: true.
  • Servers are stateless: no sessions, no ping.

Next: Transports, how messages travel through stdio and Streamable HTTP.