Protocol Architecture

By the Syncfusion DevTutorials team · Last updated

The three layers of MCP (application, message, transport), the three kinds of messages, progress updates, cancellation on each transport, and how server requests became Multi Round-Trip Requests.

Think about sending a parcel. First, you decide what to send, such as a birthday gift. Next, you pack it in a box and add a label. Finally, a truck carries the box to its destination.

The gift doesn't need to know how it is transported, and the truck doesn't need to know what is inside the box.

MCP works in a similar way, with different layers handling different jobs. Each layer has one job and doesn't need to know how the other layers do theirs. That is why the same MCP message can travel through a local pipe on your laptop or across the internet.

The main idea: MCP has three layers. The application layer says what to do. The message layer packs it as JSON-RPC. The transport layer carries the bytes.

To see how these three layers work together, let's look at a real-world example.

A Real-World Example

Maya asks Brightgrid's assistant for a sales report. The report takes a few seconds to build.

  • The host asks to use the export_report tool to build the report. That is the application layer.
  • The client packs the call as a JSON-RPC tools/call request with an id. That is the message layer.
  • The request travels over HTTP to the help-desk server. That is the transport layer.
  • While the server works, it sends progress updates, such as "part 1 of 5" and "part 2 of 5". These updates show how much work is done; you'll learn more about them later in this lesson.
  • If Maya decides to stop waiting, she can press Stop. The client sends a cancellation signal, and the server tries to stop the work.

In simple words: layers keep the jobs apart. Messages ask, answer, or give a notice. Long-running work can report progress and can be cancelled.

What You Will Learn

In this lesson, you will learn:

  1. The three layers of MCP.
  2. The three kinds of messages: requests, responses and notifications.
  3. Who may send which message in the current spec.
  4. How progress updates work.
  5. How cancellation works.
  6. How servers ask the user for input today, with Multi Round-Trip Requests.

The MCP Lab at the bottom of the page has a slow tool, export_report, that sends progress messages. It also has a Stop button, so you can try cancelling a request.

The Three Layers

The application layer decides what to do. The message layer packs it as JSON-RPC. The transport layer carries the bytes, through stdio or Streamable HTTP.

Application Layer

The application layer is about meaning. It covers the things you met in Chapter 1: tools, resources and prompts, plus the rules for discovery and for asking the user. When you write server.registerTool(...) in an SDK, you work in this layer.

Message Layer

The message layer turns each action into a JSON-RPC 2.0 message. JSON-RPC is a small standard that defines the shape of a request and its reply. A request has a method and an id, and it may also have params. A response has the same id and either a result or an error.

This layer doesn't need to understand what a method does. It makes sure messages have the right shape and that each response can be matched to its request.

Transport Layer

The transport layer moves the messages. MCP defines two standard transports:

  • stdio: the host starts the server as a program on the same computer. Messages go through the program's input and output, one JSON message per line.
  • Streamable HTTP: the server runs as a web service. Each request is an HTTP POST.

You will study both in the Transports lesson.

One call, three layers

// One tool call, wrapped by each layer in turn.

// Application layer: what the host wants.
const intent = { tool: "lookup_order", args: { order_id: "A-1001" } };

// Message layer: the same thing as a JSON-RPC request.
const message = {
  jsonrpc: "2.0",
  id: 7,
  method: "tools/call",
  params: {
    name: intent.tool,
    arguments: intent.args,
    _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
  },
};

// Transport layer: how the bytes travel.
const asStdioLine = JSON.stringify(message) + "\n";
const asHttpPost = {
  method: "POST",
  headers: { "mcp-method": "tools/call", "mcp-name": "lookup_order" },
  body: JSON.stringify(message),
};

console.log("App wants:   ", intent.tool, JSON.stringify(intent.args));
console.log("JSON-RPC:    ", message.method, "id", message.id);
console.log(
  "stdio line:  ",
  asStdioLine.length,
  "characters, ends with a new line:",
  asStdioLine.endsWith("\n"),
);
console.log(
  "HTTP request:",
  asHttpPost.method,
  "with",
  Object.keys(asHttpPost.headers).join(" + "),
);

Expected output:

App wants:    lookup_order {"order_id":"A-1001"}
JSON-RPC:     tools/call id 7
stdio line:   179 characters, ends with a new line: true
HTTP request: POST with mcp-method + mcp-name

The example showed how one tool call moves through all three layers. Next, let's look at the messages the message layer carries.

Message Types

Client Requests

A request asks for something and expects an answer. It has a method, an id and usually params. In the 2026-07-28 spec, the client sends all requests, such as server/discover, tools/list, tools/call and resources/read.

Server Replies

A response answers one request. It carries the same id as the request, and either:

  • a result, when things worked, or
  • an error, with a number code and a message, when they didn't.

Every result also says what kind of result it is, in a field called resultType. Most of the time that is "complete".

Notifications

A notification is a message that gives an update without expecting a reply. It has a method but no id. Examples:

  • notifications/progress: "I am 40% done."
  • notifications/cancelled: "Stop working on request 12."
  • notifications/tools/list_changed: "My tool list changed."

You can tell the three kinds apart by checking their fields:

Request, response or notification?

// Every MCP message is one of three kinds. You can tell them apart by their fields.
function kind(msg) {
  if ("method" in msg && "id" in msg) {
    return "request (expects an answer)";
  }
  if ("method" in msg) {
    return "notification (no answer)";
  }
  if ("result" in msg) {
    return "response: result";
  }
  if ("error" in msg) {
    return "response: error";
  }
  return "not a valid MCP message";
}

const seen = [
  { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} },
  { jsonrpc: "2.0", id: 1, result: { tools: [], resultType: "complete" } },
  {
    jsonrpc: "2.0",
    method: "notifications/progress",
    params: { progressToken: 0, progress: 1, total: 5 },
  },
  { jsonrpc: "2.0", id: 2, error: { code: -32601, message: "Method not found" } },
  { jsonrpc: "2.0", hello: "world" },
];

for (const m of seen) {
  console.log(kind(m).padEnd(28), JSON.stringify(m).slice(0, 48));
}

Expected output:

request (expects an answer)  {"jsonrpc":"2.0","id":1,"method":"tools/list","p
response: result             {"jsonrpc":"2.0","id":1,"result":{"tools":[],"re
notification (no answer)     {"jsonrpc":"2.0","method":"notifications/progres
response: error              {"jsonrpc":"2.0","id":2,"error":{"code":-32601,"
not a valid MCP message      {"jsonrpc":"2.0","hello":"world"}

For a notification sent over Streamable HTTP, the server responds with HTTP status 202 Accepted and an empty body. The notification itself gets no JSON-RPC reply.

Check the fields to identify a message: a request has both method and id; a notification has a method but no id; and a response has an id plus either result or error.

Requests, responses, and notifications describe how messages move between the client and server. Some requests take longer than one exchange, so the client and server also need a way to report progress or stop the work.

Progress and Cancellation

Progress Updates

Some tools take time: building a report, copying files, or running tests. Progress messages tell the user that the tool is still working.

  1. To request progress updates, the client adds a progressToken to the request's _meta.
  2. While it works, the server may send notifications/progress with the same token, a progress number, an optional total, and an optional message.
  3. Over HTTP, these notifications arrive on the request's own stream, before the final result.

Turn progress messages into a progress bar

// Progress notifications a server sent while it built a report.
const updates = [
  { progressToken: 0, progress: 1, total: 5, message: "Built part 1 of 5" },
  { progressToken: 0, progress: 2, total: 5, message: "Built part 2 of 5" },
  { progressToken: 0, progress: 4, total: 5, message: "Built part 4 of 5" },
  { progressToken: 0, progress: 5, total: 5, message: "Built part 5 of 5" },
];

for (const u of updates) {
  const pct = Math.round((u.progress / u.total) * 100);
  const bar = "#".repeat(pct / 10).padEnd(10, ".");
  console.log(`[${bar}] ${String(pct).padStart(3)}%  ${u.message}`);
}

Expected output:

[##........]  20%  Built part 1 of 5
[####......]  40%  Built part 2 of 5
[########..]  80%  Built part 4 of 5
[##########] 100%  Built part 5 of 5

Watch the progress lines appear one by one. Then open the Inspector: that call's row says it got 5 notifications and then the result, all on one stream.

Progress updates show that a request is still running. If the user no longer wants to wait, the client can send a cancellation signal.

Cancellation

Sometimes the user needs to stop waiting. The client sends a cancellation signal, and the server should stop the work if it can.

How the client sends that signal depends on the transport:

  • Streamable HTTP: the client closes the stream for that request. In 2026-07-28, closing the stream is the cancel signal. No extra message is sent.
  • stdio: there is only one shared channel, so the client sends a notifications/cancelled message with the requestId.

On the server, the SDK turns either signal into an abort signal, which lets the running tool know it should stop. The tool needs to check this signal and respond to it.

Cancelling on each transport

// How a client cancels a request depends on the transport.
function cancel(transport, requestId) {
  if (transport === "http") {
    return (
      "close the HTTP response stream for request " + requestId + " (no extra message)"
    );
  }
  if (transport === "stdio") {
    const note = {
      jsonrpc: "2.0",
      method: "notifications/cancelled",
      params: { requestId, reason: "user pressed stop" },
    };
    return "send " + JSON.stringify(note);
  }
  return "unknown transport";
}

console.log("Streamable HTTP:", cancel("http", 12));
console.log("stdio:          ", cancel("stdio", 12));

Expected output:

Streamable HTTP: close the HTTP response stream for request 12 (no extra message)
stdio:           send {"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":12,"reason":"user pressed stop"}}

Try it: call export_report again, and press Stop after one or two progress lines. The Inspector shows that the stream was closed early.

A cancel is a request, not a guarantee. The server may already be done, or the tool may not check the signal. Don't assume an important action, such as a payment, was cancelled just because the client sent a cancel signal. Check the result instead.

Cancellation is a signal to stop work that is already underway. Another kind of back-and-forth happens when the server needs input before it can finish. The current spec handles this with Multi Round-Trip Requests.

From Server Requests to Multi Round-Trip Requests

How It Used to Work

In older versions of MCP, the server could also send requests to the client in the middle of its work:

  • elicitation/create: "Ask the user this question."
  • sampling/createMessage: "Ask your AI model to write this."
  • roots/list: "Which folders may I see?"

This needed a long-lived, two-way connection, so the server could reach the client at any time. That made servers harder to run on the web, where each request may land on a different machine.

How It Works Now

The 2026-07-28 spec replaced this with Multi Round-Trip Requests, or MRTR:

  1. The client sends a normal request, such as tools/call.
  2. If the server needs something first, it answers with resultType: "input_required" and a list of inputRequests, such as a request to ask the user for confirmation.
  3. The client gets the answers. For example, the host shows a form to the user.
  4. The client sends the same request again, now with inputResponses attached.
  5. The server finishes and answers with resultType: "complete".

The server does not need to call the client. Each round is a client request followed by a server response. The server can also include a requestState value, which the client sends back unchanged so the server can keep track of the work between rounds.

An MRTR loop

// This simplified example shows the result exchange, not full JSON-RPC messages.
function server(params) {
  const answer = params.inputResponses?.confirm;
  if (!answer) {
    return {
      resultType: "input_required",
      inputRequests: {
        confirm: {
          method: "elicitation/create",
          params: { message: "Cancel order " + params.arguments.order_id + "?" },
        },
      },
    };
  }
  if (answer.action !== "accept") {
    return {
      resultType: "complete",
      content: [{ type: "text", text: "Nothing changed." }],
    };
  }
  return {
    resultType: "complete",
    content: [
      { type: "text", text: "Order " + params.arguments.order_id + " cancelled." },
    ],
  };
}

// The client's loop: call, and if input is required, ask the user, then call again.
let params = { name: "cancel_order", arguments: { order_id: "A-1002" } };
for (let round = 1; round <= 3; round++) {
  const r = server(params);
  console.log(`round ${round}: ${r.resultType}`);
  if (r.resultType === "complete") {
    console.log(r.content[0].text);
    break;
  }
  const question = r.inputRequests.confirm.params.message;
  console.log("  user is asked:", question, "-> accepts");
  params = {
    ...params,
    inputResponses: { confirm: { action: "accept", content: { confirm: true } } },
  };
}

Expected output:

round 1: input_required
  user is asked: Cancel order A-1002? -> accepts
round 2: complete
Order A-1002 cancelled.

You will build MRTR flows yourself in Chapter 5.

Common mistakes

  • Answering a notification. Notifications have no id, so there is nothing to answer. Over HTTP, the server just returns 202.
  • Reusing an id too soon. A client must give each open request its own id, or answers get mixed up.
  • Thinking progress is required. It is optional. A client asks for it with a progressToken, and a server sends it only if it has something useful to say.
  • Sending notifications/cancelled over Streamable HTTP in 2026-07-28. The cancel signal there is closing the stream.
  • Following old guides on server requests. Sampling, elicitation and roots requests from the server were replaced by MRTR. Chapter 5 covers what is still supported.

Exercises

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

What to Remember

  • MCP has three layers: application (what to do), message (JSON-RPC), and transport (stdio or Streamable HTTP).
  • Messages are requests (method + id), responses (id + result or error) and notifications (method, no id).
  • Every result has a resultType, usually "complete".
  • Progress: the client sends a progressToken; the server sends notifications/progress on the request's stream.
  • Cancel: over HTTP, close the stream; over stdio, send notifications/cancelled.
  • Servers no longer send requests to clients. They return input_required instead, and the client retries with inputResponses (MRTR).

Next: JSON-RPC Messages, every field of a request and a response, the core methods, and the error codes.