AI-Assisted Dev NotesRohit’s interview study guide
Chapter 04

MCP Fundamentals

The Model Context Protocol from zero: why it exists, who runs what, the JSON-RPC messages on the wire, tools, resources and prompts, the two transports, and what servers can ask of the client.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What MCP is and the problem it solves

Added

An AI assistant is only as useful as what it can see and do. GitHub Copilot in VS Code can read the files you have open, but to answer "why did the camera in huddle room 2 drop out of its call at 09:05?" it would need the device's live status, its logs and the support ticket about it. Until late 2024, every AI application solved that its own way: one plugin format for this chat app, a different extension API for that editor, hand-written function-calling glue in every in-house agent. A team that owned a useful system (a device API, a ticket tracker, a docs site) had to write and maintain one integration per AI application, and every AI application had to integrate every system separately.

The Model Context Protocol (MCP) is an open standard that removes that multiplication. The owner of a system writes one MCP server that describes what it offers (actions, data, reusable prompts) in a standard way; any AI application that speaks MCP can discover those offerings at runtime and use them, with no code specific to that server. The protocol itself is small: JSON-RPC 2.0 messages (small JSON objects with a method, params and an id that the reply echoes; The protocol on the wire shows them) for discovery ("what can you do?"), invocation ("do this"), reading context ("give me that"), plus version and capability negotiation, error conventions, two standard transports and, for remote servers, an OAuth-based authorization scheme. Anthropic announced it on 25 November 2024; on 9 December 2025 Anthropic gave MCP to the Agentic AI Foundation, a fund under the Linux Foundation co-founded with Block and OpenAI (Anthropic's and the Linux Foundation's announcements of that date). Its home is modelcontextprotocol.io.

Two mental models make it stick. The official introduction uses a hardware one: "Think of MCP like a USB-C port for AI applications." One socket shape, so any device plugs into any laptop. The more precise one for an engineer comes from the specification itself, which says MCP takes inspiration from the Language Server Protocol (LSP). Before LSP, every editor needed its own plugin for every language (Vim × Go, Emacs × Go, VS Code × Go…). LSP turned that into one language server per language plus one LSP client per editor. MCP does the same for AI applications and the systems they need.

Point-to-point integrationsWith a shared protocol
4 AI apps, 6 systems4 × 6 = 24 integrations to build and keep working4 MCP clients + 6 MCP servers = 10
A system changes its APIfix it in up to 4 places, in 4 codebasesfix its one server
A new AI app arrivesit integrates all 6 systems itselfit implements MCP once and gets all 6
Without MCP: 3 × 3 = 9 integrations Copilot (VS Code) Claude Desktop Support bot Device API Ticket tracker Docs each line is custom code someone maintains With MCP: 3 + 3 = 6 pieces Copilot + MCP client Claude Desktop + MCP client Support bot + MCP client MCP one protocol rooms server wraps device API tickets server wraps tracker docs server wraps docs any client works with any server
The N × M problem. A shared protocol turns "every app integrates every system" into "every app speaks MCP once, every system is wrapped once".

What MCP standardises, and what it deliberately leaves out:

  • In the protocol: how a server describes its tools (actions the model can ask for), resources (data the application can read into context) and prompts (templates the user can pick); how a client lists and uses them; the message format (JSON-RPC 2.0); version and capability negotiation; error conventions; the stdio and Streamable HTTP transports; and an OAuth 2.1-based authorization flow for servers reached over HTTP.
  • Not in the protocol: which LLM you use, how the model decides to call a tool, the chat UI, how approvals look, or what a server does internally. Those belong to the host application and the server author. MCP doesn't make a model smarter; it makes the plumbing between models and systems standard.

Here's the whole idea in one run: one server program, written once, used unchanged by two different AI applications that contain no code specific to it. Each "host" launches its own copy of the server as a child process (the stdio transport), discovers its tools at runtime and calls one.

one-server-two-hosts.mjs
// One MCP server program, used unchanged by two different "AI apps".
import { fileURLToPath } from "node:url";

if (process.argv[2] === "server") {
  // Written once, by the team that owns the room devices.
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
  const { z } = await import("zod"); // zod: a schema library; the SDK turns these schemas into JSON Schema for the client
  const server = new McpServer({ name: "rooms", version: "1.0.0" });
  server.registerTool("get_room_status", { description: "Status of a meeting room", inputSchema: { roomId: z.string() } },
    async ({ roomId }) => {
      const who = server.server.getClientVersion().name; // the client said who it is during initialize
      console.error(`[rooms server] ${who} asked about ${roomId}`); // stderr: logs, never stdout
      return { content: [{ type: "text", text: `${roomId}: in a call, camera on` }] };
    });
  await server.connect(new StdioServerTransport());
} else {
  // Two different hosts. Neither contains any code specific to the rooms server.
  const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
  const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
  const config = { command: process.execPath, args: [fileURLToPath(import.meta.url), "server"] }; // like a host's config entry
  for (const hostName of ["ide-assistant", "support-chat"]) {
    const client = new Client({ name: hostName, version: "1.0.0" });
    const transport = new StdioClientTransport({ ...config, stderr: "pipe" });
    let serverLog = "";
    transport.stderr.on("data", (d) => (serverLog += d));
    await client.connect(transport); // spawns its own copy of the server process
    const { tools } = await client.listTools(); // discovered at runtime, not compiled in
    const result = await client.callTool({ name: tools[0].name, arguments: { roomId: "huddle-2" } });
    await client.close();
    console.log(`${hostName}: found [${tools.map((t) => t.name)}] → "${result.content[0].text}"`);
    console.log(`  ${serverLog.trim()}`);
  }
}
Result
ide-assistant: found [get_room_status] → "huddle-2: in a call, camera on"
  [rooms server] ide-assistant asked about huddle-2
support-chat: found [get_room_status] → "huddle-2: in a call, camera on"
  [rooms server] support-chat asked about huddle-2
What’s happening
  1. The file plays both parts. Run with the argument server, it builds an McpServer named rooms with one tool, get_room_status, and connects it to a StdioServerTransport: from then on it reads JSON-RPC messages from its stdin and writes replies to its stdout. Run without arguments, it plays two host applications.
  2. config is the only thing each host knows about the server: a command and its arguments. That's exactly what a real host's configuration file holds for a local MCP server (the VS Code and Claude Desktop formats are in Connecting a server to a host). StdioClientTransport spawns that command as a child process, so ide-assistant and support-chat each get their own server process.
  3. client.connect(transport) runs the opening handshake (you'll see its exact messages in The protocol on the wire). During it the client introduces itself by name, which is why the server can print ide-assistant asked about huddle-2 from server.server.getClientVersion().name.
  4. client.listTools() returns [get_room_status]. Neither host had that name compiled in: it was discovered at runtime. That's the N + M point: the hosts implemented MCP once; the server implemented MCP once; nobody wrote a "rooms integration" for either host.
  5. The server writes its log line with console.error, to stderr, and the host collects it separately. On the stdio transport stdout is the protocol channel; a stray console.log there corrupts the message stream (Transports: stdio and Streamable HTTP shows what breaks).
  6. client.close() closes the child's stdin; the server sees end-of-file and exits. Run it again and you get the same output: the second host's server process is a fresh copy, sharing nothing with the first.
AdvancedWhere MCP fits, and where it doesn't
  • Where it shows up: AI coding tools (the MCP introduction lists VS Code, Cursor and others among supporting clients; Agent mode and the coding agent covers how Copilot uses MCP servers), desktop assistants (Claude, ChatGPT), and in-house agents. Anthropic's December 2025 announcement put the number of active public MCP servers above 10,000.
  • For a device platform: the REST API that the web UI already calls could be wrapped as an MCP server so a support engineer's assistant can read a room's status and logs, with guarded write actions. That's Chapter 7.
  • When it pays off: a capability is useful to more than one AI application, or is built by a different team from the app that uses it, or you want users to plug in tools you didn't build.
  • When it's overkill: one application calling its own internal function, in one codebase, with one model. Plain function calling in the model API does that without a protocol, a process and a version to manage.
  • What it costs: another process or network hop, version and capability negotiation to get right, and a real security surface: a server's tool descriptions and results go straight into the model's context (Prompt injection and tool poisoning).
Counting integrations

Your company has 5 AI applications (two IDE assistants, a support chatbot, an internal agent, a Slack bot) and 8 systems they should reach. How many integrations do you maintain without MCP, how many pieces with MCP, and what happens in each case when the ticketing system changes its API?

Show answer

Without MCP: 40 integrations. With MCP: 13 pieces (5 clients + 8 servers). An API change touches up to 5 codebases without MCP and exactly 1 server with it.

What’s happening
  1. Point-to-point, every app needs its own code for every system: 5 × 8 = 40.
  2. With MCP each app implements the client side once (5), and each system gets one server (8): 5 + 8 = 13.
  3. When ticketing changes its API, every app that integrated it directly must change: up to 5 fixes in 5 codebases, probably owned by different teams.
  4. With MCP only the tickets server changes. As long as its tool names and schemas stay the same, no host notices; if they do change, hosts rediscover them through tools/list instead of needing new code.
  5. The general rule: point-to-point grows as N × M, a shared protocol as N + M. That's the argument for any protocol of this kind, LSP and MCP alike.

#Hosts, clients and servers

Added

MCP has three roles, and most confusion about it comes from mixing them up. The host is the AI application the user actually runs: VS Code with Copilot Chat, Claude Desktop, Claude Code, or an agent you build. It owns the conversation, the model, the UI and the user's consent. Inside the host, a client is a connector object that speaks MCP to exactly one server: the host creates one client per server it connects to. A server is a program that exposes tools, resources and prompts, either as a local process the host launches or as a remote service it calls over HTTP. The specification calls this a client-host-server architecture and is explicit that each client has a 1:1 relationship with one server.

A good mental model is a hotel concierge. The guest (the user) only ever talks to the concierge (the host). The concierge has a separate phone line (a client) to each supplier (a server): the taxi firm, the restaurant, the theatre. The concierge decides which supplier to ring and what to tell them; the taxi firm never hears the guest's conversation with the restaurant, and suppliers never ring each other. That's the isolation MCP's design principles ask for: servers get only the context they need, the full conversation stays with the host, and servers can't see into each other.

The model (LLM) sits with the host, not with the servers. The host sends the model the conversation plus the list of tools it gathered from all its clients; when the model asks for a tool, the host routes the call to the right client, which sends it to its server. Servers do the work but never decide when to act.

Host: e.g. VS Code + Copilot owns the conversation, the model and consent Chat UI user, approvals LLM picks tools Client A 1:1 with rooms Client B 1:1 with files Client C 1:1 with tickets host routes calls rooms server child process files server child process tickets server remote service stdio stdio HTTP ↑ this machine ↓ over the network Device REST API the real system Project folder Ticket tracker Servers only see what their client sends them: never the whole chat, never each other.
One host, one client per server. Local servers are child processes on stdio; remote ones are services reached over Streamable HTTP. The LLM and the user's consent stay in the host.
RoleWho builds itWhat it doesExamples
HostThe AI app's vendor, or you for an in-house agentRuns the UI and the model, creates one client per configured server, merges their tools for the model, asks the user before risky actions, enforces security policyVS Code with Copilot Chat, Claude Desktop, Claude Code, your own agent
ClientUsually an SDK class used by the hostSpeaks MCP to one server: negotiates the version and capabilities, sends requests, routes notifications, keeps that server's state separateClient from @modelcontextprotocol/sdk/client
ServerThe owner of a system (or anyone wrapping it)Declares and implements tools, resources and prompts; enforces its own access control; can be local or remoteA rooms server wrapping a device REST API, GitHub's MCP server, a filesystem server

Here is a toy host doing the host's three jobs with real SDK clients and servers: one client per server, one merged tool list for the model (with names prefixed so two search tools don't collide), and an approval check before anything that isn't read-only. The "model" is a scripted list of tool requests, standing in for the LLM.

host.mjs
// A toy host: one Client per server, one merged tool list for the model, approval before anything risky.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";

function roomsServer() {
  const s = new McpServer({ name: "rooms", version: "1.0.0" });
  s.registerTool("search", { description: "Find rooms by name", inputSchema: { q: z.string() }, annotations: { readOnlyHint: true } },
    async ({ q }) => ({ content: [{ type: "text", text: ["huddle-2", "board-1"].filter((r) => r.includes(q)).join(", ") }] }));
  s.registerTool("reboot_room", { description: "Reboot a room device", inputSchema: { roomId: z.string() }, annotations: { destructiveHint: true } },
    async ({ roomId }) => ({ content: [{ type: "text", text: `${roomId} rebooting` }] }));
  return s;
}
function ticketsServer() {
  const s = new McpServer({ name: "tickets", version: "1.0.0" });
  s.registerTool("search", { description: "Find support tickets", inputSchema: { q: z.string() }, annotations: { readOnlyHint: true } },
    async ({ q }) => ({ content: [{ type: "text", text: `T-81 "${q} has no audio" (open)` }] }));
  return s;
}

// The host: starts one client per configured server and keeps them apart.
const clients = {};
for (const [name, make] of Object.entries({ rooms: roomsServer, tickets: ticketsServer })) {
  const [c, s] = InMemoryTransport.createLinkedPair();
  await make().connect(s);
  clients[name] = new Client({ name: "toy-host", version: "1.0.0" });
  await clients[name].connect(c);
}
const catalogue = new Map(); // what the model sees → which client, which real tool
for (const [server, client] of Object.entries(clients))
  for (const tool of (await client.listTools()).tools) catalogue.set(`${server}.${tool.name}`, { server, tool });
console.log("tools offered to the model:", [...catalogue.keys()].join(", "));

const askUser = (text) => { const yes = !text.includes("reboot"); console.log(`  ask user: ${text} → ${yes ? "allow" : "deny"}`); return yes; };
const modelWants = [ // scripted, standing in for the LLM's tool-use requests
  ["rooms.search", { q: "huddle" }],
  ["tickets.search", { q: "huddle-2" }],
  ["rooms.reboot_room", { roomId: "huddle-2" }],
];
for (const [qualified, args] of modelWants) {
  const { server, tool } = catalogue.get(qualified);
  console.log(`model asks for ${qualified}(${JSON.stringify(args)})`);
  const autoRun = tool.annotations?.readOnlyHint === true; // a hint, trusted only because we trust these servers
  if (!autoRun && !askUser(`${server} wants to run ${tool.name} ${JSON.stringify(args)}`)) {
    console.log("  → not sent; the model is told the user declined");
    continue;
  }
  const result = await clients[server].callTool({ name: tool.name, arguments: args });
  console.log(`  → sent to the ${server} client only; result: ${result.content[0].text}`);
}
for (const client of Object.values(clients)) await client.close();
Result
tools offered to the model: rooms.search, rooms.reboot_room, tickets.search
model asks for rooms.search({"q":"huddle"})
  → sent to the rooms client only; result: huddle-2
model asks for tickets.search({"q":"huddle-2"})
  → sent to the tickets client only; result: T-81 "huddle-2 has no audio" (open)
model asks for rooms.reboot_room({"roomId":"huddle-2"})
  ask user: rooms wants to run reboot_room {"roomId":"huddle-2"} → deny
  → not sent; the model is told the user declined
What’s happening
  1. Two independent servers are built: rooms (tools search and reboot_room) and tickets (tool search). Both have a tool called search. Tool names only have to be unique within one server, so this is legal, and it happens all the time in real hosts.
  2. The host loop creates a separate InMemoryTransport pair (the SDK's in-process transport: two linked ends that hand messages to each other directly, with no child process or network, which is handy for demos and tests) and a separate Client for each server: clients.rooms and clients.tickets. That's the 1:1 rule. Each client negotiates on its own, so each server can support different versions and capabilities, fail on its own, and never sees the other's traffic.
  3. The host asks each client for tools/list and builds catalogue, keyed rooms.search, rooms.reboot_room, tickets.search. The prefix is the host's own disambiguation: the spec says hosts that aggregate tools from several servers should handle such collisions, for example by prefixing with a server identifier, and warns that a server's self-reported name isn't guaranteed unique. In a real host the config key you gave the server is a safer prefix.
  4. The scripted "model" asks for three calls. For rooms.search and tickets.search the tool's readOnlyHint is true, so this host runs them without asking, and each call goes only to the client that owns the tool: tickets.search never touches the rooms server.
  5. For rooms.reboot_room there's no readOnlyHint (its annotations say destructiveHint: true), so the host asks the user, who denies. Nothing is sent to the server at all; the host tells the model the user declined. Approval is a host feature: the protocol carries the hints, the host decides what to do with them.
  6. The comment on autoRun matters: annotations are hints from the server, and the spec says clients must treat them as untrusted unless the server is trusted. A malicious server can mark a destructive tool readOnlyHint: true. This toy trusts its two servers because it built them.
AdvancedWhat happens when a host starts

In a host such as VS Code or Claude Desktop the steps are the same as in the toy, driven by a config file: the host reads its list of MCP servers; for each local server it launches the configured command as a child process and wraps its stdin/stdout in a client; for each remote server it creates a client pointed at the URL (and runs the OAuth flow if the server demands it). Each client runs the handshake, the host fetches tool lists, and the tools appear in the chat's tool picker. If a server crashes, only its client is affected. Connecting a server to a host shows the real config files.

Counting the pieces

VS Code with Copilot Chat is configured with three MCP servers: a filesystem server and a rooms server, both launched locally with node, and GitHub's remote MCP server over HTTPS. How many hosts, clients and servers are there, and how many extra processes run on the laptop?

Show answer

1 host, 3 clients, 3 servers, and 2 extra processes on the laptop.

What’s happening
  1. The host is the application the user runs: VS Code with Copilot Chat. One host.
  2. The host creates one client per configured server: 3 clients, all inside the VS Code process (they're objects, not processes).
  3. There are 3 servers, but they don't all run on the laptop.
  4. The two local servers use stdio, so the host spawns each as a child process: 2 extra processes, each started and stopped by VS Code.
  5. GitHub's server is remote: it runs on GitHub's infrastructure, and the laptop only holds an HTTP connection to it. That's why remote servers need real authorization (OAuth) while local stdio servers typically take credentials from environment variables.

#The protocol on the wire

Added

Every MCP message is a JSON-RPC 2.0 message: a small JSON object with "jsonrpc": "2.0". There are only four shapes. A request has an id, a method and optional params, and expects exactly one reply. A result response carries the same id and a result; an error response carries the same id and an error with an integer code and a message. A notification has a method but no id, and the receiver must not reply to it. MCP adds a few rules on top of plain JSON-RPC: request ids must be strings or integers and never null, and a sender must not reuse an id (2025-11-25: never within the same session; 2026-07-28, which has no sessions: not while a request with that id is still waiting for its answer). In the 2025-11-25 revision both sides can send requests, and each side numbers its own.

The mental model is a phone call with a fixed opening, in the 2025-11-25 revision that SDK 1.32.2 speaks. The client rings and says "I speak version 2025-11-25, and I can do roots and elicitation" (initialize). The server answers "I speak 2025-11-25 too, and I offer tools and resources" (the initialize result). The client says "great, let's start" (notifications/initialized, a notification, so no reply). After that every question carries a ticket number (the id), every answer quotes the ticket number, and an "FYI" without a ticket number is a notification. Capability negotiation is that opening exchange: each side lists the optional features it supports, and both must stick to features the other declared.

The current revision, 2026-07-28, removed the opening entirely. Each request now carries its own "I speak version X and can do Y" in a _meta field, so any request can go to any server instance with no prior conversation, which is what lets remote servers run behind a plain load balancer. The diagram shows both; the runnable models below show the first for real.

2025-11-25: handshake first what SDK 1.32.2 speaks client server initialize (version, caps, info) result (version, caps, info) notifications/initialized agreed once, remembered for the session tools/list (id 2) result (id 2): tools tools/call (id 3) result (id 3): content dashed arrow = notification: no id, no reply 2026-07-28: every request stands alone current spec (SDK v2) client server server/discover (optional) versions, caps, serverInfo nothing remembered between requests tools/list + _meta resultType "complete": tools tools/call + _meta resultType "complete": content _meta = version + client caps any instance can serve any request
The same conversation in the two eras. 2025-11-25 negotiates once and remembers; 2026-07-28 repeats the version and capabilities on every request, so there's nothing to remember.

Here is the 2025-11-25 conversation for real. The parent process plays the client by hand, writing raw JSON-RPC lines to the stdin of a child process that runs a real SDK server, and printing every line in both directions exactly as it crossed the pipe (→ client to server, ← server to client).

wire.mjs
// The parent plays the MCP client by hand; the child is a real SDK server on stdio.
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { createInterface } from "node:readline";

if (process.argv[2] === "server") {
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
  const { z } = await import("zod");
  const server = new McpServer({ name: "rooms", version: "1.0.0" });
  server.registerTool(
    "get_room_status",
    { description: "Current status of a meeting room device", inputSchema: { roomId: z.string() } },
    async ({ roomId }) => ({ content: [{ type: "text", text: `${roomId}: in a call, camera on` }] }),
  );
  await server.connect(new StdioServerTransport()); // reads stdin, writes stdout
} else {
  const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "server"], {
    stdio: ["pipe", "pipe", "inherit"],
  });
  const replies = new Map(); // request id → resolve function
  createInterface({ input: child.stdout }).on("line", (line) => {
    console.log(`← ${line}`);
    const msg = JSON.parse(line);
    replies.get(msg.id)?.(msg);
  });
  const send = (msg) => {
    const line = JSON.stringify(msg); // one message = one line, no embedded newlines
    console.log(`→ ${line}`);
    child.stdin.write(line + "\n");
    if (msg.id !== undefined) return new Promise((resolve) => replies.set(msg.id, resolve));
  };

  await send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {
    protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "by-hand", version: "0.1.0" } } });
  send({ jsonrpc: "2.0", method: "notifications/initialized" }); // a notification: no id, no reply
  await send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
  await send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: {
    name: "get_room_status", arguments: { roomId: "huddle-2" } } });
  child.stdin.end(); // closing stdin is how a stdio client shuts the server down
}
Result
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"by-hand","version":"0.1.0"}}}
← {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"rooms","version":"1.0.0"}},"jsonrpc":"2.0","id":1}
→ {"jsonrpc":"2.0","method":"notifications/initialized"}
→ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
← {"result":{"tools":[{"name":"get_room_status","description":"Current status of a meeting room device","inputSchema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"roomId":{"type":"string"}},"required":["roomId"]},"execution":{"taskSupport":"forbidden"}}]},"jsonrpc":"2.0","id":2}
→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_room_status","arguments":{"roomId":"huddle-2"}}}
← {"result":{"content":[{"type":"text","text":"huddle-2: in a call, camera on"}]},"jsonrpc":"2.0","id":3}
What’s happening
  1. initialize (id 1) is the first message, as the 2025-11-25 lifecycle requires. It carries the client's chosen protocolVersion (the latest it supports, per the spec), its capabilities (empty here: this hand-written client offers no roots, sampling or elicitation) and clientInfo. Note the whole message is one line: on stdio, messages are newline-delimited and must not contain embedded newlines.
  2. The server's reply has the same id: 1 and a result with three things the client must keep: the version it agreed to (2025-11-25, the same as requested because it supports it), its capabilities (tools with listChanged: true: "I offer tools and will tell you when the list changes"), and serverInfo. The SDK filled in that capability by itself because a tool was registered; you never wrote it. The SDK also writes result before jsonrpc; key order in JSON objects carries no meaning.
  3. notifications/initialized has no id, so nothing comes back: the send helper doesn't wait for this one. It tells the server the client is ready; the spec says a server shouldn't send its own requests (other than pings and logging) before receiving it.
  4. tools/list (id 2) returns the tool's definition. inputSchema is a JSON Schema the SDK generated from the zod shape { roomId: z.string() }: an object with a required string roomId. It also added "$schema": "http://json-schema.org/draft-07/schema#", which is allowed (2025-11-25 made 2020-12 the default dialect but lets a schema name another), and execution: { taskSupport: "forbidden" }, which is about the experimental tasks feature of 2025-11-25: this tool can't be run as a background task.
  5. tools/call (id 3) names the tool and passes arguments that match the schema. The reply's result.content is a list of content blocks, here one text block. There's no isError field, which means success.
  6. child.stdin.end() is the stdio shutdown: the client closes the server's input, the server reads end-of-file and exits, and the parent then exits too. There's no "goodbye" message in MCP; the transport closing is the signal.
  7. This is everything an SDK does under client.connect() and client.callTool(). Nothing about it is magic: newline-separated JSON objects, ids to pair questions with answers, and an agreed opening.
AdvancedCapability negotiation in 2025-11-25

Capabilities are how each side says which optional parts of the protocol it supports. The base protocol (initialize, requests, results, errors, notifications, ping) is always there; everything else is opt-in. The 2025-11-25 lifecycle page lists:

SideCapabilityWhat it promisesSub-capabilities
Clientrootscan tell the server which folders/URIs are in scopelistChanged
Clientsamplingthe server may ask the host's LLM for a completiontools (sampling with tool calls)
Clientelicitationthe server may ask the user for inputform, url
Client / servertasksexperimental: long-running requests as pollable tasksper request type
Servertoolstools/list, tools/calllistChanged
Serverresourcesresources/list, resources/read, templatessubscribe, listChanged
Serverpromptsprompts/list, prompts/getlistChanged
Serverloggingthe server can send log messages
Servercompletionsargument auto-completion (completion/complete)
Bothexperimentalnon-standard features, by agreement

The rule after the handshake is simple: only use what the other side declared. The models below show what the SDK actually does when someone breaks that rule.

AdvancedVersion negotiation, the era probe, and an undeclared method

This model talks raw JSON-RPC again, but asks awkward questions: a 2026-07-28-style discovery probe first, a request before the handshake, a version from the future, and a method for a capability the server never declared.

negotiate.mjs
// What a client learns from the server's replies before and during the handshake.
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { createInterface } from "node:readline";

if (process.argv[2] === "server") {
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
  const server = new McpServer({ name: "rooms", version: "1.0.0" });
  server.registerTool("list_rooms", { description: "All meeting rooms" }, async () => ({
    content: [{ type: "text", text: "huddle-2, board-1" }],
  }));
  await server.connect(new StdioServerTransport());
} else {
  const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "server"], { stdio: ["pipe", "pipe", "inherit"] });
  const replies = new Map();
  createInterface({ input: child.stdout }).on("line", (line) => {
    const msg = JSON.parse(line);
    replies.get(msg.id)?.(msg);
  });
  let nextId = 1;
  const ask = (label, method, params) => {
    const id = nextId++;
    child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
    return new Promise((resolve) => replies.set(id, resolve)).then((msg) => {
      console.log(`${label.padEnd(37)} ${JSON.stringify(msg.error ?? msg.result)}`);
      return msg;
    });
  };

  // 1. A dual-era client probes with the 2026-07-28 discovery call first.
  const probe = await ask("server/discover (2026-07-28 probe)", "server/discover", {
    _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {} },
  });
  const modernCodes = [-32020, -32021, -32022];
  const era = probe.result || modernCodes.includes(probe.error?.code) ? "modern" : "legacy";
  console.log(`→ ${era} server: fall back to initialize`);

  // 2. Before initialize: the spec says don't, but does this server refuse?
  await ask("tools/list before initialize", "tools/list");

  // 3. Ask for a version the server doesn't know.
  const init = await ask("initialize asking for 2099-01-01", "initialize", {
    protocolVersion: "2099-01-01", capabilities: {}, clientInfo: { name: "probe", version: "0" },
  });
  const agreed = init.result.protocolVersion;
  console.log(`→ server answered ${agreed}; a client that can't speak it should disconnect`);
  child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");

  // 4. A method for a capability the server didn't declare.
  await ask("prompts/list (no prompts capability)", "prompts/list");
  child.stdin.end();
}
Result
server/discover (2026-07-28 probe)    {"code":-32601,"message":"Method not found"}
→ legacy server: fall back to initialize
tools/list before initialize          {"tools":[{"name":"list_rooms","description":"All meeting rooms","inputSchema":{"type":"object","properties":{}},"execution":{"taskSupport":"forbidden"}}]}
initialize asking for 2099-01-01      {"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"rooms","version":"1.0.0"}}
→ server answered 2025-11-25; a client that can't speak it should disconnect
prompts/list (no prompts capability)  {"code":-32601,"message":"Method not found"}
What’s happening
  1. The probe. A client that supports both eras is told by the 2026-07-28 stdio binding to send server/discover first, with its preferred modern version in _meta. A modern server answers with a DiscoverResult or a modern error (codes -32020 to -32022); anything else, or silence, means a legacy server. SDK 1.32.2 is legacy, so it answers -32601 Method not found, and the client falls back to initialize. The spec says the fallback must not depend on one particular error code, which is why the model checks "is it a modern code?" rather than "is it -32601?".
  2. Before the handshake. 2025-11-25 says a client shouldn't send anything but pings before initialize completes, but this server answers tools/list anyway. That leniency is exactly why the 2026-07-28 spec recommends that even modern-only clients probe first: some legacy servers would otherwise process an ambiguous method like tools/call under legacy rules instead of failing cleanly.
  3. A version it doesn't know. The client asks for 2099-01-01; the server doesn't support it, so (as the spec requires) it answers with a version it does support, its latest, 2025-11-25. Negotiation is "client proposes, server picks". It's then the client's job to disconnect if it can't speak the version the server picked.
  4. An undeclared method. The server's capabilities were only tools, and the client asks for prompts/list. The SDK server never registered a handler for it, so the reply is a plain JSON-RPC -32601 Method not found. Capabilities told the client in advance that this would happen; a well-behaved client wouldn't have asked.
  5. The point: version negotiation happens once, in initialize, and capability negotiation is a promise both sides keep afterwards. Nothing on later messages repeats it, which is the property 2026-07-28 changed.
AdvancedWhat the SDK client does with the server's capabilities

The SDK Client stores the server's answer and exposes it. By default it doesn't stop you calling a method the server didn't declare; with enforceStrictCapabilities: true it refuses locally, before anything is sent.

client-checks.mjs
// The client side of capability negotiation: what the SDK Client does with the server's answer.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";

for (const strict of [false, true]) {
  const server = new McpServer({ name: "rooms", version: "1.0.0" }, { instructions: "Read-only room data." });
  server.registerTool("list_rooms", { description: "All meeting rooms" }, async () => ({ content: [{ type: "text", text: "huddle-2" }] }));
  const client = new Client({ name: "host", version: "1.0.0" }, { enforceStrictCapabilities: strict });
  const [c, s] = InMemoryTransport.createLinkedPair();
  let sent = 0;
  const send = c.send.bind(c);
  c.send = (m, o) => (sent++, send(m, o)); // count what actually leaves the client
  await server.connect(s);
  await client.connect(c);
  console.log(`enforceStrictCapabilities: ${strict}`);
  if (!strict) {
    console.log("  server capabilities:", JSON.stringify(client.getServerCapabilities()));
    console.log("  server instructions:", client.getInstructions());
  }
  for (const call of [() => client.listTools(), () => client.listPrompts(), () => client.listResources()]) {
    const before = sent;
    try {
      const r = await call();
      console.log(`  ${Object.keys(r)[0]}: ok`);
    } catch (e) {
      console.log(`  ${e.message} (${sent - before} sent)`);
    }
  }
  await client.close();
}
Result
enforceStrictCapabilities: false
  server capabilities: {"tools":{"listChanged":true}}
  server instructions: Read-only room data.
  tools: ok
  MCP error -32601: Method not found (1 sent)
  MCP error -32601: Method not found (1 sent)
enforceStrictCapabilities: true
  tools: ok
  Server does not support prompts (required for prompts/list) (0 sent)
  Server does not support resources (required for resources/list) (0 sent)
What’s happening
  1. The server registers one tool and passes instructions, a free-text hint for the host ("how to use this server"), which comes back in the initialize result. client.getServerCapabilities() shows only tools, and client.getInstructions() returns the hint so a host can add it to the model's system prompt.
  2. With the default enforceStrictCapabilities: false, listPrompts() and listResources() are sent (the counter shows 1 sent) and come back as -32601 Method not found from the server. You pay a round trip to learn what the handshake already told you.
  3. With enforceStrictCapabilities: true, the same calls throw Server does not support prompts (required for prompts/list) with 0 messages sent. The client checked its stored copy of the server's capabilities first.
  4. A real host normally reads getServerCapabilities() once after connecting and simply doesn't show prompts or resources for a server that lacks them. Strict mode is a safety net for code paths that forget to check.
AdvancedWhat changes on the wire in 2026-07-28

The current revision keeps JSON-RPC 2.0 and the method names, but removes the handshake and the session. These shapes are from the 2026-07-28 specification; SDK 1.32.2 can't produce them, so they weren't run here.

Request in 2026-07-28 (from the spec's server/discover and transport pages)
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_room_status",
    "arguments": { "roomId": "huddle-2" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}
  • No initialize / notifications/initialized. Every request carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities (both required) in params._meta, and should carry clientInfo. A request missing a required field is rejected with -32602. Servers should put io.modelcontextprotocol/serverInfo in each result's _meta.
  • server/discover is a new method every server must implement. It returns supportedVersions, capabilities, optional instructions and serverInfo. Calling it is optional for clients; on stdio it doubles as the era probe you saw above.
  • Per-request negotiation. If the server doesn't support the request's version it returns UnsupportedProtocolVersionError (-32022, with data.supported and data.requested), and the client retries with a version from the list. If handling the request needs a client capability that wasn't declared, the server returns MissingRequiredClientCapabilityError (-32021, with data.requiredCapabilities).
  • Every result has resultType: "complete", or "input_required" when the server needs more input (see Client features: roots, sampling and elicitation). Clients treat a missing resultType from an older server as "complete".
  • Cache hints. List results and resources/read carry ttlMs and cacheScope ("public" or "private"), and servers should return tools/list in a deterministic order so hosts and LLM prompt caches can reuse it.
  • Removed: ping, logging/setLevel (the log level is now a per-request _meta key), notifications/roots/list_changed, HTTP sessions, and server-initiated requests.
CodeNameWhere it comes from
-32700Parse errorJSON-RPC 2.0: the bytes weren't valid JSON
-32600Invalid requestJSON-RPC 2.0: valid JSON, not a valid request
-32601Method not foundJSON-RPC 2.0: no handler for that method (and the legacy-server answer to the probe)
-32602Invalid paramsJSON-RPC 2.0: bad arguments; MCP also uses it for unknown tools, unknown prompts and (from 2026-07-28) unknown resources
-32603Internal errorJSON-RPC 2.0: the handler failed
-32000 to -32019implementation-defined2026-07-28 calls these legacy; SDK 1.32.2 uses -32000 (connection closed / bad HTTP request) and -32001 (request timeout, session not found)
-32002Resource not found2025-11-25 and earlier only; clients should still accept it
-32020HeaderMismatch2026-07-28: HTTP headers don't match the body
-32021MissingRequiredClientCapability2026-07-28
-32022UnsupportedProtocolVersion2026-07-28
What does the server say?

Using the server from the negotiation model (SDK 1.32.2, one tool), a client sends initialize with "protocolVersion": "2025-06-18". What protocolVersion comes back, and why is that different from the 2099-01-01 case?

Show answer

It comes back as 2025-06-18: the server supports that older revision, so it must answer with the same version.

What’s happening
  1. The 2025-11-25 rule: if the server supports the requested version, it must respond with the same version; otherwise it responds with another version it supports, preferably its latest.
  2. SDK 1.32.2's list of supported versions (SUPPORTED_PROTOCOL_VERSIONS in its types.js) is 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07, the same list its HTTP transport printed in an error message in the transports model.
  3. 2025-06-18 is on the list, so the server echoes it, and the session runs with 2025-06-18 behaviour. A new server keeps working with an older client.
  4. 2099-01-01 isn't on the list, so the server answered with its latest, 2025-11-25, and left the decision to the client: carry on if it can speak 2025-11-25, otherwise disconnect.
  5. In 2026-07-28 the same situation is handled per request: an unsupported version gets -32022 with the server's supported list, and the client retries the request with one of those versions.

#Tools

Added

A tool is an action the server offers and the model decides to use: query a device, create a ticket, run a search. Tools are MCP's standard form of function calling. The server describes each tool in tools/list with a name, a description and an inputSchema (a JSON Schema for its arguments), optionally a title for display, an outputSchema for structured results and annotations that hint at its behaviour. The host passes those descriptions to its LLM alongside the conversation; when the model emits a tool request, the host (after any approval) sends tools/call with the name and arguments, and hands the result back to the model. The spec calls tools model-controlled and, in the same breath, says there should always be a human able to deny a tool call.

The mental model: a tool definition is a recipe card for a stranger. The model never sees your code, your API docs or your team's conventions; it sees the name, one paragraph of description and the form to fill in. If the card is vague ("updates stuff"), the model will guess, and guesses on a device platform reboot rooms. Good names, precise descriptions that say when to use the tool, and tight schemas (enums, ranges, required fields) are the whole user interface. Designing good tools goes deep on that.

FieldRequiredWhat it's for
nameyesProgrammatic id, unique within the server. 2025-11-25 guidance: 1–128 characters, only A–Z a–z 0–9 _ - ., case-sensitive
titlenoHuman-friendly display name for the host's UI
descriptionin practice, yesWhat it does and when to use it. The model reads this to choose
inputSchemayesJSON Schema object for arguments. For no arguments: { "type": "object" }, or, as both 2025-11-25 and 2026-07-28 recommend, { "type": "object", "additionalProperties": false }
outputSchemanoJSON Schema for structuredContent; if present the server must conform and clients should validate
annotationsnoBehaviour hints: readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title. Untrusted unless the server is trusted
iconsnoImages for the UI (added in 2025-11-25)

A result has content, a list of blocks (text, image, audio, resource_link, or an embedded resource), optionally structuredContent (typed JSON matching outputSchema), and isError. The diagram shows where each message goes in a real call.

User LLM Host + client MCP server Device API 1 "Is huddle-2 in a call?" 2 chat + tool list 3 get_room_status 4 approval policy ask user if risky 5 tools/call 6 GET /rooms/… 7 JSON 8 result content 9 tool result 10 answer text 11 "Yes, camera on" MCP: steps 5 and 8 only
A tool call end to end. Steps 2, 3, 9 and 10 are the host's own LLM API calls; MCP carries only the tools/call request and its result. The approval step belongs to the host.

Here is a well-described tool with an output schema and annotations: what the SDK puts on the wire for it, and what a call returns.

tool-definition.mjs
// One well-described tool: what the SDK puts on the wire, and what a call returns.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";

const rooms = { "huddle-2": { inCall: true, camera: "on", firmware: "4.3.1" } };
const server = new McpServer({ name: "rooms", version: "1.0.0" });
server.registerTool(
  "get_room_status",
  {
    title: "Room status",
    description: "Live status of one meeting-room device. Use list_rooms first if you don't know the id.",
    inputSchema: { roomId: z.string().describe("Room id, e.g. huddle-2") },
    outputSchema: { roomId: z.string(), inCall: z.boolean(), camera: z.enum(["on", "off"]), firmware: z.string() },
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  async ({ roomId }) => {
    const status = { roomId, ...rooms[roomId] };
    return {
      content: [{ type: "text", text: JSON.stringify(status) }], // for hosts that only read text
      structuredContent: status, // typed data matching outputSchema
    };
  },
);

const client = new Client({ name: "host", version: "1.0.0" });
const [c, s] = InMemoryTransport.createLinkedPair();
await server.connect(s);
await client.connect(c);

const { tools } = await client.listTools();
console.log("tools/list returned one tool:");
for (const [key, value] of Object.entries(tools[0])) if (value !== undefined) console.log(`  ${key}: ${JSON.stringify(value)}`);
const result = await client.callTool({ name: "get_room_status", arguments: { roomId: "huddle-2" } });
console.log("tools/call returned:");
for (const [key, value] of Object.entries(result)) console.log(`  ${key}: ${JSON.stringify(value)}`);
await client.close();
Result
tools/list returned one tool:
  name: "get_room_status"
  title: "Room status"
  description: "Live status of one meeting-room device. Use list_rooms first if you don't know the id."
  inputSchema: {"type":"object","properties":{"roomId":{"type":"string","description":"Room id, e.g. huddle-2"}},"required":["roomId"],"$schema":"http://json-schema.org/draft-07/schema#"}
  outputSchema: {"type":"object","properties":{"roomId":{"type":"string"},"inCall":{"type":"boolean"},"camera":{"type":"string","enum":["on","off"]},"firmware":{"type":"string"}},"required":["roomId","inCall","camera","firmware"],"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":false}
  annotations: {"readOnlyHint":true,"openWorldHint":false}
  execution: {"taskSupport":"forbidden"}
tools/call returned:
  content: [{"type":"text","text":"{\"roomId\":\"huddle-2\",\"inCall\":true,\"camera\":\"on\",\"firmware\":\"4.3.1\"}"}]
  structuredContent: {"roomId":"huddle-2","inCall":true,"camera":"on","firmware":"4.3.1"}
What’s happening
  1. registerTool takes a name, a config and a handler. The config's inputSchema and outputSchema are zod shapes; the SDK converts them to JSON Schema for tools/list (you can see .describe("Room id, e.g. huddle-2") became the property's description, and z.enum(["on", "off"]) became "enum": ["on","off"]). The model sees that description, so it's worth writing.
  2. The listed tool carries everything a host needs: title for its UI, description for the model, both schemas, and annotations saying it's read-only and closed-world (it only touches this server's rooms, not the open internet). A host like the toy in Hosts, clients and servers can use readOnlyHint to skip a confirmation, if it trusts the server.
  3. The SDK added additionalProperties: false to the output schema, so a result with extra fields would fail validation, and $schema draft-07 to both. execution.taskSupport: "forbidden" again marks it as not runnable as a 2025-11-25 task.
  4. On tools/call the SDK validates the arguments against the input schema before your handler runs, so inside the handler roomId is guaranteed to be a string.
  5. The handler returns both content (a text block with the JSON as a string) and structuredContent (the same data as an object). The spec says a tool that returns structured content should also return it serialised as text, for hosts that only pass text to the model; hosts that understand outputSchema can use the typed object directly. The SDK checked structuredContent against outputSchema before sending.
  6. isError is absent: success. Errors are the next model.
AdvancedTwo kinds of error

The spec separates protocol errors (JSON-RPC errors: unknown tool, malformed request, server failure) from tool execution errors (a normal result with isError: true: the API failed, an argument is out of range, a business rule said no). The difference matters because hosts are told to pass tool execution errors to the model so it can correct itself, while protocol errors are less likely to be shown to it. Here's what SDK 1.32.2's McpServer actually returns in six cases.

tool-errors.mjs
// Five ways a tool call can go wrong, and what the client gets back for each.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";

const server = new McpServer({ name: "rooms", version: "1.0.0" });
server.registerTool("set_volume", { description: "Set speaker volume", inputSchema: { roomId: z.string(), level: z.number().int().min(0).max(100) } },
  async ({ roomId, level }) => {
    if (roomId === "board-1") throw new Error("ECONNREFUSED 10.0.4.17:443"); // an unexpected failure
    if (roomId === "lobby") // an expected business failure, written for the model to act on
      return { isError: true, content: [{ type: "text", text: "lobby has no speaker. Rooms with speakers: huddle-2, board-1." }] };
    return { content: [{ type: "text", text: `${roomId} volume ${level}` }] };
  });
server.registerTool("get_firmware", { description: "Firmware version", inputSchema: { roomId: z.string() }, outputSchema: { version: z.string() } },
  async () => ({ content: [{ type: "text", text: "4.3.1" }], structuredContent: { version: 431 } })); // wrong type

const client = new Client({ name: "host", version: "1.0.0" });
const [c, s] = InMemoryTransport.createLinkedPair();
await server.connect(s);
await client.connect(c);

const cases = [
  ["works", "set_volume", { roomId: "huddle-2", level: 40 }],
  ["unknown tool", "set_volum", { roomId: "huddle-2", level: 40 }],
  ["bad arguments", "set_volume", { roomId: "huddle-2", level: 140 }],
  ["handler throws", "set_volume", { roomId: "board-1", level: 40 }],
  ["isError on purpose", "set_volume", { roomId: "lobby", level: 40 }],
  ["output breaks schema", "get_firmware", { roomId: "huddle-2" }],
];
for (const [label, name, args] of cases) {
  try {
    const r = await client.callTool({ name, arguments: args });
    console.log(`${label.padEnd(21)} isError=${r.isError ?? false} ${r.content[0].text}`);
  } catch (e) {
    console.log(`${label.padEnd(21)} threw (code ${e.code}): ${e.message}`);
  }
}
await client.close();
Result
works                 isError=false huddle-2 volume 40
unknown tool          isError=true MCP error -32602: Tool set_volum not found
bad arguments         isError=true MCP error -32602: Input validation error: Invalid arguments for tool set_volume: Too big: expected number to be <=100 at level
handler throws        isError=true ECONNREFUSED 10.0.4.17:443
isError on purpose    isError=true lobby has no speaker. Rooms with speakers: huddle-2, board-1.
output breaks schema  isError=true MCP error -32602: Output validation error: Invalid structured content for tool get_firmware: Invalid input: expected string, received number at version
What’s happening
  1. works: a normal result, isError absent (printed as false).
  2. unknown tool (set_volum, a typo a model could make): the SDK returns a result with isError: true and the text MCP error -32602: Tool set_volum not found. The 2025-11-25 and 2026-07-28 specs both list "unknown tool" as a protocol error (a JSON-RPC error -32602). This is a real variance: McpServer.registerTool's tools/call handler catches every error and wraps it with createToolError (you can read it in dist/esm/server/mcp.js). The upside is that the model sees the message and can retry with the right name.
  3. bad arguments (level: 140 against max(100)): the SDK's input validation fails before the handler runs and comes back as isError: true with zod's message. That matches 2025-11-25, which clarified that input validation errors should be tool execution errors so the model can fix its arguments.
  4. handler throws: an unexpected exception becomes isError: true with the exception's message, ECONNREFUSED 10.0.4.17:443. It works, but look at what it told the model: an internal IP address and port. Catch errors and return a message written for the model.
  5. isError on purpose: the best kind of error. The handler knows what went wrong and says what to do instead ("Rooms with speakers: huddle-2, board-1"). The model can recover in one step.
  6. output breaks schema: the handler returned { version: 431 } against version: z.string(). The SDK validated the output on the server and turned it into isError: true instead of sending invalid structured data. A client should validate too; the SDK client does when it has the tool's outputSchema cached from tools/list.
AdvancedAnnotations are hints, not guarantees

Annotations were added in 2025-03-26 to describe behaviour so hosts can make better UI and approval decisions. The defaults below are from the SDK's ToolAnnotationsSchema comments, which match the spec.

AnnotationMeaning when trueDefaultTypical host use
readOnlyHintdoesn't modify its environmentfalsemay run without confirmation
destructiveHintmay delete or overwrite (only meaningful if not read-only)truestronger warning
idempotentHintrepeating with the same arguments has no extra effectfalsesafe to retry
openWorldHinttalks to an open set of external things (web search) rather than a closed domaintrueflag data leaving the system

Read the defaults carefully: a tool with no annotations is assumed to be non-read-only, destructive, non-idempotent and open-world. That's deliberately pessimistic. And because a malicious server can lie, the spec says clients must treat annotations as untrusted unless they come from a trusted server.

AdvancedMore tool rules worth knowing
  • List changes. A server that declared tools.listChanged can send notifications/tools/list_changed; the host then calls tools/list again. In 2026-07-28 that notification arrives only on a subscriptions/listen stream the client opened with toolsListChanged: true.
  • No per-connection lists (2026-07-28). tools/list must not vary per connection or as a side effect of earlier requests, but may vary by the authorization on the request (only show tools the caller's scopes allow). Servers should return tools in a deterministic order.
  • State across calls (2026-07-28 guidance). With no protocol session, a server that needs state (a basket, a browser context, a transaction) returns an explicit handle from one tool ({ "basket_id": "bsk_a1b2c3" }) and accepts it as an argument on the next; the model carries it forward. Validate authorization against the handle on every call, keep it opaque, state its lifetime in the description, and return a clear tool error when it has expired.
  • x-mcp-header (2026-07-28). A property in inputSchema can be marked so its value is mirrored into an Mcp-Param-{Name} HTTP header, letting gateways route on it without parsing the body. Never mark secrets that way: headers are visible to every intermediary.
Protocol error or tool error?

For each case, should the server return a JSON-RPC error or a result with isError: true (per the 2025-11-25 spec)? (a) The model passes "date": "31/02/2026". (b) The device API times out. (c) The request has no name field at all. (d) The user isn't allowed to reboot that room.

Show answer

(a) isError result. (b) isError result. (c) JSON-RPC error (-32602 Invalid params). (d) isError result, with a message saying why.

What’s happening
  1. (a) is an input validation problem the model can fix by choosing another date. 2025-11-25 explicitly moved input validation errors to tool execution errors so the model gets the message and self-corrects.
  2. (b) is an external API failure, one of the spec's examples of a tool execution error. Say whether a retry makes sense ("timed out after 5 s; the room may be offline").
  3. (c) means the request doesn't match the tools/call schema at all. The spec lists "malformed requests (requests that fail to satisfy CallToolRequest schema)" as a protocol error, because there's nothing for the model to "correct" in the tool's terms, and the client library is probably buggy. The spec doesn't name the code; -32602 Invalid params is the JSON-RPC code for bad parameters. SDK 1.32.2 actually answers a tools/call with no name with -32603 (Internal error) and zod's validation message (re-checked by hand while reviewing this page; not one of the page's models).
  4. (d) is a business rule. Returning it as a tool result lets the model explain it to the user ("you need the room-admin role") instead of seeing a generic failure.
  5. Rule of thumb: if the model could do something useful with the message, it's a tool error; if the request itself is broken, it's a protocol error. Remember that SDK 1.32.2's McpServer returns even an unknown tool as isError: true, as the previous model showed.

#Resources and resource templates

Added

A resource is a piece of data the server makes available as context: a file, a database schema, a runbook, a device's current status. Each one is identified by a URI (docs://runbooks/no-audio, rooms://huddle-2/status, file:///project/README.md), has a name, optionally a title, description, mimeType and size, and its contents are either text or base64 blob. The client lists them with resources/list and reads one with resources/read. The spec calls resources application-controlled: the host decides how they reach the model, for example through an "attach context" picker, an @-mention, or automatic inclusion by its own heuristics. The model doesn't fetch resources by itself unless the host gives it a way to.

A resource template describes a whole family of resources with an RFC 6570 URI template, like rooms://{roomId}/status. The server doesn't have to list every room up front; the client (or the user, with auto-completion via completion/complete) fills in roomId and reads the resulting URI. Servers can also tell clients when a resource changes: in 2025-11-25 the client calls resources/subscribe for a URI and gets notifications/resources/updated; in 2026-07-28 that moved to a subscriptions/listen stream.

The mental model: tools are the buttons a server offers the model; resources are the documents on a shared drive it publishes for the application. Pressing a button does something (and the model chooses when); opening a document only reads (and the app or the user chooses which ones go into the conversation). When you're unsure which to build, ask "who should decide when this is used?"

ToolResource
Controlled bythe modelthe application (often on the user's choice)
Identified bya name, with an input schemaa URI (or a URI template)
Side effectsmay have themshould have none: it's a read
Typical UIruns during the agent loop, with approval if riskycontext picker, @-mentions, attachments
Good foractions and parameterised queriesdocuments, schemas, logs, status snapshots
resources.mjs
// A fixed resource, a resource template, and what reading them looks like.
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";

const rooms = { "huddle-2": { inCall: true, firmware: "4.3.1" }, "board-1": { inCall: false, firmware: "4.2.0" }, lobby: null };
const server = new McpServer({ name: "rooms", version: "1.0.0" });

// 1. One fixed URI.
server.registerResource("runbook", "docs://runbooks/no-audio",
  { title: "Runbook: no audio", mimeType: "text/markdown" },
  async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: "# No audio\n1. Check mute\n2. Check USB" }] }));

// 2. A family of URIs: rooms://{roomId}/status
server.registerResource("room-status",
  new ResourceTemplate("rooms://{roomId}/status", {
    list: async () => ({ resources: Object.keys(rooms).map((id) => ({ uri: `rooms://${id}/status`, name: `${id} status` })) }),
  }),
  { title: "Room status", mimeType: "application/json" },
  async (uri, { roomId }) => {
    if (!(roomId in rooms)) throw new McpError(ErrorCode.InvalidParams, `No room called ${roomId}`, { uri: uri.href });
    if (rooms[roomId] === null) throw new Error(`${roomId} device is offline`); // a plain Error
    return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(rooms[roomId]) }] };
  });

const client = new Client({ name: "host", version: "1.0.0" });
const [c, s] = InMemoryTransport.createLinkedPair();
await server.connect(s);
await client.connect(c);

console.log("resources/list:");
for (const r of (await client.listResources()).resources) console.log(`  ${r.uri}  (${r.name}, ${r.mimeType})`);
console.log("resources/templates/list:");
for (const t of (await client.listResourceTemplates()).resourceTemplates) console.log(`  ${t.uriTemplate}  (${t.name})`);
for (const uri of ["docs://runbooks/no-audio", "rooms://board-1/status", "rooms://atrium/status", "rooms://lobby/status", "rooms://board-1/logs"]) {
  try {
    const { contents } = await client.readResource({ uri });
    console.log(`read ${uri} → ${contents[0].mimeType}: ${JSON.stringify(contents[0].text)}`);
  } catch (e) {
    console.log(`read ${uri} → error ${e.code}: ${e.message}`);
  }
}
await client.close();
Result
resources/list:
  docs://runbooks/no-audio  (runbook, text/markdown)
  rooms://huddle-2/status  (huddle-2 status, application/json)
  rooms://board-1/status  (board-1 status, application/json)
  rooms://lobby/status  (lobby status, application/json)
resources/templates/list:
  rooms://{roomId}/status  (room-status)
read docs://runbooks/no-audio → text/markdown: "# No audio\n1. Check mute\n2. Check USB"
read rooms://board-1/status → application/json: "{\"inCall\":false,\"firmware\":\"4.2.0\"}"
read rooms://atrium/status → error -32602: MCP error -32602: MCP error -32602: No room called atrium
read rooms://lobby/status → error -32603: MCP error -32603: lobby device is offline
read rooms://board-1/logs → error -32602: MCP error -32602: MCP error -32602: Resource rooms://board-1/logs not found
What’s happening
  1. A fixed resource: registerResource("runbook", "docs://runbooks/no-audio", metadata, read) publishes one URI. The scheme docs:// is made up: the spec lists https://, file:// and git:// as common schemes and allows custom ones as long as they follow RFC 3986. It recommends https:// only for things the client could fetch from the web itself.
  2. A template: new ResourceTemplate("rooms://{roomId}/status", { list }) registers the family. Its list callback is what makes resources/list show the concrete rooms (huddle-2, board-1, lobby), and resources/templates/list shows the template itself, rooms://{roomId}/status, so a host can offer "type a room id". The SDK requires you to pass list (even as undefined) so you don't forget listing by accident.
  3. Reading returns contents, a list (a single read may return several items, for example the files in a directory), each with its uri, mimeType and text. For rooms://board-1/status the SDK matched the template and passed { roomId: "board-1" } to the read callback. The status JSON travels as text with mimeType: application/json; binary data would be a blob.
  4. atrium doesn't exist, so the callback throws McpError(ErrorCode.InvalidParams, …) and the client gets -32602. The SDK client's message repeats the MCP error -32602: prefix because the server's message already contained it and the client wraps it again; read e.code, not the text.
  5. lobby exists but is offline, and the callback throws a plain Error. The SDK turns that into -32603 Internal error with your message. The client can't tell "you asked for nonsense" from "the server is broken" unless you choose the code.
  6. rooms://board-1/logs matches neither the fixed URI nor the template, so the SDK itself answers -32602 Resource … not found. The 2025-11-25 spec named -32002 for "resource not found"; 2026-07-28 changed it to -32602 and tells clients to accept both. SDK 1.32.2 already sends -32602.
AdvancedTelling the client a resource changed

In 2025-11-25, a server that supports it declares resources.subscribe; the client calls resources/subscribe with a URI, and the server sends notifications/resources/updated when that resource changes. The notification carries only the URI: the client reads the resource again to get the new contents. SDK 1.32.2's McpServer has no helper for subscriptions, so this model declares the sub-capability and handles the two methods on the low-level Server it wraps (mcp.server).

subscribe.mjs
// 2025-11-25 resource subscriptions: subscribe, get told "it changed", read again.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { SubscribeRequestSchema, UnsubscribeRequestSchema, ResourceUpdatedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";

const device = { status: "idle" };
const watched = new Set(); // URIs this connection subscribed to
const mcp = new McpServer({ name: "rooms", version: "1.0.0" });
mcp.registerResource("huddle-2-status", "rooms://huddle-2/status", { mimeType: "application/json" },
  async (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(device) }] }));
// McpServer 1.32.2 has no subscribe helper: declare the sub-capability, handle the methods on the low-level Server.
mcp.server.registerCapabilities({ resources: { subscribe: true } });
mcp.server.setRequestHandler(SubscribeRequestSchema, async ({ params }) => (watched.add(params.uri), {}));
mcp.server.setRequestHandler(UnsubscribeRequestSchema, async ({ params }) => (watched.delete(params.uri), {}));
async function deviceChanged(status) { // e.g. a webhook from the device
  device.status = status;
  for (const uri of watched) await mcp.server.sendResourceUpdated({ uri });
}

const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
let started = false; // print the wire only after the handshake
const send = clientSide.send.bind(clientSide);
clientSide.send = (m, o) => (started && console.log("→", JSON.stringify(m)), send(m, o));
let onmessage;
Object.defineProperty(clientSide, "onmessage", { get: () => onmessage,
  set: (fn) => (onmessage = (m, e) => (started && console.log("←", JSON.stringify(m)), fn(m, e))) });

const client = new Client({ name: "host", version: "1.0.0" });
const updated = new Promise((resolve) =>
  client.setNotificationHandler(ResourceUpdatedNotificationSchema, async (n) => resolve(n.params.uri)));
await mcp.connect(serverSide);
await client.connect(clientSide);
console.log("server capabilities:", JSON.stringify(client.getServerCapabilities()));
started = true;
await client.subscribeResource({ uri: "rooms://huddle-2/status" });
await deviceChanged("in-call");
const uri = await updated;
const { contents } = await client.readResource({ uri });
console.log("host now has:", contents[0].text);
await client.close();
Result
server capabilities: {"resources":{"subscribe":true,"listChanged":true}}
→ {"method":"resources/subscribe","params":{"uri":"rooms://huddle-2/status"},"jsonrpc":"2.0","id":1}
← {"result":{},"jsonrpc":"2.0","id":1}
← {"method":"notifications/resources/updated","params":{"uri":"rooms://huddle-2/status"},"jsonrpc":"2.0"}
→ {"method":"resources/read","params":{"uri":"rooms://huddle-2/status"},"jsonrpc":"2.0","id":2}
← {"result":{"contents":[{"uri":"rooms://huddle-2/status","mimeType":"application/json","text":"{\"status\":\"in-call\"}"}]},"jsonrpc":"2.0","id":2}
host now has: {"status":"in-call"}
What’s happening
  1. mcp.server.registerCapabilities({ resources: { subscribe: true } }) adds the sub-capability; the printed server capabilities show "subscribe": true next to the listChanged the SDK set on its own. Without it, a client must assume resources/subscribe isn't supported, and a plain McpServer would answer it with -32601 Method not found.
  2. The host sends resources/subscribe for rooms://huddle-2/status and gets an empty result {}. The server remembers the URI in watched. That memory is per connection: subscriptions are state, which is one reason 2026-07-28 redesigned them.
  3. deviceChanged("in-call") stands in for the real trigger (a device webhook, a poll). It calls sendResourceUpdated({ uri }) for each watched URI, which sends a notification: no id, so the host doesn't reply.
  4. The notification says only which resource changed. The host's handler resolves updated, and the host sends resources/read (id 2) to fetch the new contents: {"status":"in-call"}. Keeping notifications small and letting the client pull avoids pushing data to clients that no longer care.
  5. In 2026-07-28, resources/subscribe and resources/unsubscribe are gone. The client sends one long-lived subscriptions/listen request naming what it wants (resourceSubscriptions with URIs, toolsListChanged, and so on); the server acknowledges with notifications/subscriptions/acknowledged and tags each notification with io.modelcontextprotocol/subscriptionId. The state is scoped to that request, not to a connection.
AdvancedAnnotations, caching and safety
  • Annotations on resources (and on content blocks) are hints for the host: audience (["user"], ["assistant"] or both), priority from 0.0 to 1.0, and lastModified as an ISO 8601 timestamp. A host can use them to decide what to put into a limited context window.
  • Caching (2026-07-28). resources/list, resources/templates/list and resources/read results carry ttlMs (how long the result stays fresh) and cacheScope ("public" lets shared intermediaries cache it; "private" doesn't).
  • Security. The spec requires servers to validate every resource URI and, for file:// resources, to sanitise paths against directory traversal (file:///project/../../etc/passwd); it recommends access controls on sensitive resources. A resource template is user-controlled input.
  • Not found means an error. Servers must not return an empty contents array for a resource that doesn't exist: an empty list would mean "exists, but empty".

#Prompts

Added

A prompt is a reusable, parameterised message template that a server offers for the user to pick, typically as a slash command or a menu item in the host's chat. The server author packages know-how ("how we triage a meeting room") as a named prompt with arguments; prompts/list returns each prompt's name, title, description and arguments, and prompts/get with argument values returns the expanded messages, each with a role (user or assistant) and content (text, image, audio, a resource link or an embedded resource). The host inserts those messages into the conversation. The spec calls prompts user-controlled: the user chooses when to use one, while the server decides what it contains.

The mental model is a saved email template with blanks. You pick "Room triage" from the menu, fill in the room id (with auto-complete), and a well-written opening message, including a snapshot of the room's status, appears in the chat as if you'd typed it. That's useful for a team: the best way to ask about a problem is written once, by the people who know the system, and everyone gets it.

Prompts complete the set of three server primitives. Side by side, which is the comparison interviewers like:

PrimitiveControlled byWhat the user seesMethods (2025-11-25)Device-platform example
Toolsthe modelthe agent calling get_room_status, with an approval prompt for risky onestools/list, tools/callread status, set volume, reboot (with confirmation)
Resourcesthe applicationan "attach context" picker or @-mentionresources/list, resources/read, resources/templates/list, resources/subscriberunbooks, a room's config, the last hour of logs
Promptsthe usera slash command such as /triage-roomprompts/list, prompts/get"triage a room", "write a firmware rollout note"

Here is a prompt with a required and an optional argument, auto-completion for the room id and an embedded resource in its output.

prompts.mjs
// A prompt: a named, parameterised message template the user picks, e.g. from a "/" menu.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";

const rooms = ["huddle-2", "huddle-3", "board-1"];
const server = new McpServer({ name: "rooms", version: "1.0.0" });
server.registerPrompt(
  "triage-room",
  {
    title: "Triage a room",
    description: "Start a structured troubleshooting conversation about one room",
    argsSchema: {
      roomId: completable(z.string(), (typed) => rooms.filter((id) => id.startsWith(typed))),
      symptom: z.string().optional(),
    },
  },
  ({ roomId, symptom }) => ({
    messages: [
      { role: "user", content: { type: "text", text: `Room ${roomId} reports: ${symptom ?? "unknown problem"}. ` +
          "Read its status first, then list likely causes, most likely first. Don't change settings without asking me." } },
      { role: "user", content: { type: "resource", resource: { uri: `rooms://${roomId}/status`, mimeType: "application/json", text: '{"inCall":false}' } } },
    ],
  }),
);

const client = new Client({ name: "host", version: "1.0.0" });
const [c, s] = InMemoryTransport.createLinkedPair();
await server.connect(s);
await client.connect(c);

console.log("server capabilities:", JSON.stringify(client.getServerCapabilities()));
const { prompts } = await client.listPrompts();
console.log("prompts/list:", JSON.stringify(prompts));
const typed = await client.complete({ ref: { type: "ref/prompt", name: "triage-room" }, argument: { name: "roomId", value: "hud" } });
console.log("completion/complete for 'hud':", JSON.stringify(typed.completion));
const got = await client.getPrompt({ name: "triage-room", arguments: { roomId: "huddle-3", symptom: "no audio" } });
got.messages.forEach((m, i) => console.log(`message ${i + 1} (${m.role}, ${m.content.type}):`, m.content.text ?? m.content.resource.uri));
try {
  await client.getPrompt({ name: "triage-room", arguments: {} });
} catch (e) {
  console.log("prompts/get without roomId → error", e.code);
}
await client.close();
Result
server capabilities: {"completions":{},"prompts":{"listChanged":true}}
prompts/list: [{"name":"triage-room","title":"Triage a room","description":"Start a structured troubleshooting conversation about one room","arguments":[{"name":"roomId","required":true},{"name":"symptom","required":false}]}]
completion/complete for 'hud': {"values":["huddle-2","huddle-3"],"total":2,"hasMore":false}
message 1 (user, text): Room huddle-3 reports: no audio. Read its status first, then list likely causes, most likely first. Don't change settings without asking me.
message 2 (user, resource): rooms://huddle-3/status
prompts/get without roomId → error -32602
What’s happening
  1. registerPrompt("triage-room", { title, description, argsSchema }, callback) declares the prompt. Prompt arguments are strings on the wire (the user types them), so the zod shape uses z.string(), with .optional() for symptom. Registering a prompt made the SDK declare prompts: { listChanged: true }, and using completable made it declare completions: {} too: the printed capabilities show both.
  2. prompts/list returns the definition a host uses to build its menu: name, title, description and arguments with required flags (roomId true, symptom false). Note there's no JSON Schema here, unlike tools: prompt arguments are a flat list of named strings.
  3. Completion: as the user types hud, the host calls completion/complete with a reference to the prompt and the partial argument, and the completable callback returns ["huddle-2", "huddle-3"] with total and hasMore. The same mechanism completes resource template variables (ref/resource instead of ref/prompt).
  4. prompts/get with { roomId: "huddle-3", symptom: "no audio" } runs the callback, which returns two user messages: the instruction text (including a ground rule, "don't change settings without asking me") and an embedded resource, the room's status as rooms://huddle-3/status. The host places both into the conversation, so the model starts with the instructions and the data.
  5. Calling prompts/get without the required roomId fails argument validation, and the SDK returns -32602 Invalid params, which is what the spec asks for (invalid prompt name or missing required arguments → -32602).
AdvancedWhere prompts fit, and the catch
  • Prompts are how a server team shares good practice with everyone who uses its server: the right first questions, the order to check things, the safety rules. For a distributed team that's a cheap way to make the assistant behave consistently (Helping a distributed team use AI tools well).
  • Hosts don't all support every primitive in the same way, and the protocol doesn't mandate a UI for any of them. Check each host's own documentation (covered in Chapter 2 for Copilot) before relying on prompts or resources.
  • The catch: a prompt's content is written by the server, and it goes into the conversation with the user's authority. A malicious or compromised server can hide instructions in it. The spec requires implementations to validate prompt inputs and outputs, and a careful host shows the user the expanded messages before sending them. Prompt injection and tool poisoning covers the attacks.
  • In 2026-07-28, prompts/get (like tools/call and resources/read) may answer with input_required to ask the client for more input before it can produce the messages, and list results carry ttlMs/cacheScope.
Which primitive?

Pick tool, resource or prompt for each, and say why: (a) the current firmware release notes; (b) restarting a room's video codec; (c) a standard "draft a customer update about this outage" request; (d) looking up which rooms have firmware older than 4.3, as part of a longer task.

Show answer

(a) resource, (b) tool, (c) prompt, (d) tool.

What’s happening
  1. (a) Release notes are a document the user might attach to the conversation: read-only, identified by a URI such as docs://firmware/4.3.1/notes, application-controlled. A resource.
  2. (b) Restarting something is an action with side effects that the model might decide to take mid-troubleshooting. A tool, with no readOnlyHint, ideally destructiveHint: true, so the host asks the user first.
  3. (c) A standard way of asking that the user deliberately triggers: a prompt, maybe /customer-update with an incidentId argument, expanding into well-written instructions.
  4. (d) It's a read, but a parameterised query the model decides to run during a task. A tool such as find_rooms({ firmwareBelow: "4.3" }) fits better than a resource, because the model, not the user, chooses when and with what filter.
  5. The test each time is "who decides when this is used?": model → tool, application/user picking data → resource, user picking a workflow → prompt.

#Transports: stdio and Streamable HTTP

Added

The protocol says what the messages are; a transport says how the bytes travel. MCP defines two standard transports and allows custom ones. With stdio, the host launches the server as a child process and they talk over its standard streams: the server reads JSON-RPC messages from stdin and writes them to stdout, one message per line, and may write logs to stderr. With Streamable HTTP, the server is an independent web service with a single endpoint (say https://mcp.example.com/mcp): the client POSTs each JSON-RPC message to it, and the server answers each request either with one JSON object or with a Server-Sent Events (SSE) stream that can carry related notifications (progress, logs) before the final response. Streamable HTTP arrived in 2025-03-26 and replaced the older HTTP+SSE transport from 2024-11-05, which is now formally Deprecated.

The mental model: a stdio server is a helper the host hires and keeps in the back room. It's started and stopped by the host, has a private line to it, runs on the user's machine with the user's permissions, and serves exactly one client. An HTTP server is a shop across town: it runs on its own, many clients can call it, it needs an address and a way to check who's calling (OAuth), and it can be scaled and deployed like any web service.

stdio: local child process Host spawns the server Server process user's permissions stdin stdout stderr one JSON message per line; stderr = logs only close stdin to shut it down Streamable HTTP: a web service MCP client in any host Server POST /mcp POST request JSON or SSE notification 202 many clients; OAuth; check Origin SSE carries progress, then the response
The two standard transports. The JSON-RPC messages are identical; only the framing and who runs the server differ.
stdioStreamable HTTP
Who runs the serverthe host, as a child processthe server's owner, as a service
Clients per serveronemany
Where it runsthe user's machine, with the user's permissionsanywhere reachable: your cloud, a vendor's, or localhost
Credentialsthe spec says stdio servers shouldn't use the MCP authorization spec and should take credentials from the environment (env vars in the host config)OAuth 2.1-based authorization, defined by the spec for HTTP (Authorization for remote servers)
Typical uselocal files, git, a CLI wrapper, a dev's own toolsshared team servers, SaaS integrations (GitHub's, a ticket tracker's), anything multi-user
Deploying an updateevery user updates their copyupdate the service once

Streamable HTTP as SDK 1.32.2 implements it (the 2025-11-25 shape, with sessions), driven by plain fetch so you can see the status codes, headers and SSE framing:

http-wire.mjs
// The same MCP messages over Streamable HTTP (2025-11-25 shape, as SDK 1.32.2 implements it).
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

const mcp = new McpServer({ name: "rooms", version: "1.0.0" });
mcp.registerTool("list_rooms", { description: "All meeting rooms" }, async () => ({ content: [{ type: "text", text: "huddle-2, board-1" }] }));
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
await mcp.connect(transport);
const http = createServer((req, res) => transport.handleRequest(req, res));
await new Promise((resolve) => http.listen(0, "127.0.0.1", resolve)); // port 0: any free port
const url = `http://127.0.0.1:${http.address().port}/mcp`;

let session; // random per run, so it's printed as <session-id>
const hide = (s) => (session ? s.replaceAll(session, "<session-id>") : s);
async function post(label, body, headers = {}) {
  const res = await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...headers },
    body: JSON.stringify(body),
  });
  session ??= res.headers.get("mcp-session-id");
  const sid = res.headers.get("mcp-session-id");
  console.log(`${label}: ${res.status} ${res.headers.get("content-type") ?? "(empty body)"}${sid ? `, Mcp-Session-Id: ${hide(sid)}` : ""}`);
  const text = (await res.text()).trimEnd();
  if (text) console.log(hide(text).split("\n").map((line) => `    ${line}`).join("\n"));
}

await post("POST initialize", { jsonrpc: "2.0", id: 1, method: "initialize",
  params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "fetch", version: "0" } } });
const sessionHeaders = { "mcp-session-id": session, "mcp-protocol-version": "2025-11-25" };
await post("POST notifications/initialized", { jsonrpc: "2.0", method: "notifications/initialized" }, sessionHeaders);
await post("POST tools/call", { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "list_rooms", arguments: {} } }, sessionHeaders);
await post("POST tools/list, no session header", { jsonrpc: "2.0", id: 3, method: "tools/list" }, { "mcp-protocol-version": "2025-11-25" });
await post("POST tools/list, unknown session", { jsonrpc: "2.0", id: 4, method: "tools/list" }, { ...sessionHeaders, "mcp-session-id": "made-up" });
await post("POST tools/list as 2026-07-28", { jsonrpc: "2.0", id: 5, method: "tools/list" }, { ...sessionHeaders, "mcp-protocol-version": "2026-07-28" });
await transport.close();
http.close();
Result
POST initialize: 200 text/event-stream, Mcp-Session-Id: <session-id>
    event: message
    data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"rooms","version":"1.0.0"}},"jsonrpc":"2.0","id":1}
POST notifications/initialized: 202 (empty body)
POST tools/call: 200 text/event-stream, Mcp-Session-Id: <session-id>
    event: message
    data: {"result":{"content":[{"type":"text","text":"huddle-2, board-1"}]},"jsonrpc":"2.0","id":2}
POST tools/list, no session header: 400 application/json
    {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Mcp-Session-Id header is required"},"id":null}
POST tools/list, unknown session: 404 application/json
    {"jsonrpc":"2.0","error":{"code":-32001,"message":"Session not found"},"id":null}
POST tools/list as 2026-07-28: 400 application/json
    {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Unsupported protocol version: 2026-07-28 (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)"},"id":null}
What’s happening
  1. The server is an ordinary Node http server on port 0 (any free port, so the output never depends on the port) that hands every request to StreamableHTTPServerTransport.handleRequest. With sessionIdGenerator: () => randomUUID() the transport is stateful: it creates a session at initialize. The model prints the random id as <session-id> so the output is the same every run.
  2. POST initialize must send Accept: application/json, text/event-stream (the client has to accept both). The reply is 200 with Content-Type: text/event-stream, an Mcp-Session-Id header, and a body with one SSE event, event: message, whose data: line is the same JSON-RPC result you saw on stdio. The server chose SSE; it could also have answered with plain JSON.
  3. POST notifications/initialized is a notification, so there's nothing to answer: 202 Accepted with an empty body, as the spec requires. From here the client sends Mcp-Session-Id and MCP-Protocol-Version: 2025-11-25 on every request (the version header became mandatory in 2025-06-18).
  4. POST tools/call with the session header works: one SSE event containing the result. A slow tool could send notifications/progress events on this same stream before the final response.
  5. Without the session header the request is rejected with 400 and -32000 Bad Request: Mcp-Session-Id header is required; with a made-up session id, 404 and -32001 Session not found (the 2025-11-25 transport says a client that gets 404 for its session must start a new one with initialize). Sessions are server-side state, so a second server instance behind a load balancer wouldn't know this session either, unless you add sticky routing or shared session storage.
  6. A request labelled 2026-07-28 is refused with 400 and an error listing the versions this SDK supports. That's the "legacy server" signal: the 2026-07-28 spec tells a dual-era client to look at a 400's body and, if it isn't a recognised modern error, fall back to initialize.
AdvancedWhat a stray log line does to stdio

Three copies of the same server, differing only in how the tool handler logs: console.error, console.log, and process.stdout.write without a newline. The host is the SDK Client with StdioClientTransport, and the call has a 1-second timeout.

stdio-logging.mjs
import { fileURLToPath } from "node:url";
if (process.argv[2] === "server") {
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
  const server = new McpServer({ name: "chatty", version: "1.0.0" });
  const mode = process.argv[3];
  server.registerTool("status", { description: "Device status" }, async () => {
    if (mode === "console.error") console.error("debug: status called"); // stderr: not the protocol channel
    if (mode === "console.log") console.log("debug: status called"); // stdout: an extra, invalid line
    if (mode === "stdout.write") process.stdout.write("debug: "); // stdout, no newline: glued to the reply
    return { content: [{ type: "text", text: "online" }] };
  });
  await server.connect(new StdioServerTransport());
} else {
  const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
  const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
  for (const mode of ["console.error", "console.log", "stdout.write"]) {
    const transport = new StdioClientTransport({ command: process.execPath, args: [fileURLToPath(import.meta.url), "server", mode], stderr: "pipe" });
    let stderr = "";
    transport.stderr.on("data", (d) => (stderr += d));
    const client = new Client({ name: "host", version: "1.0.0" });
    const errors = [];
    client.onerror = (e) => errors.push(e.message);
    await client.connect(transport);
    let result;
    try {
      const r = await client.callTool({ name: "status", arguments: {} }, undefined, { timeout: 1000 });
      result = r.content[0].text;
    } catch (e) {
      result = e.message;
    }
    console.log(`${mode}:`);
    console.log(`  result:          ${result}`);
    console.log(`  transport error: ${errors[0] ?? "none"}`);
    console.log(`  stderr:          ${JSON.stringify(stderr)}`);
    await client.close();
  }
}
Result
console.error:
  result:          online
  transport error: none
  stderr:          "debug: status called\n"
console.log:
  result:          online
  transport error: Unexpected token 'd', "debug: status called" is not valid JSON
  stderr:          ""
stdout.write:
  result:          MCP error -32001: Request timed out
  transport error: Unexpected token 'd', "debug: {"r"... is not valid JSON
  stderr:          ""
What’s happening
  1. console.error writes to stderr. The client transport was created with stderr: "pipe", so the host captured the log separately ("debug: status called\n"), and the protocol channel was untouched: result online, no transport error. This is the right way.
  2. console.log writes debug: status called plus a newline to stdout, the protocol channel. The client's line reader gets a line that isn't JSON and reports a transport error (Unexpected token 'd'…) through client.onerror. The real response arrived on its own line afterwards, so the call still returned online: lucky, and noisy in the host's logs.
  3. process.stdout.write("debug: ") has no newline, so it glues itself to the start of the next line, which is the JSON-RPC response. The client sees debug: {"result":… as one line, can't parse it, and the response is lost. The call waits until its timeout: MCP error -32001: Request timed out. In a real host with a long default timeout this looks like a hung tool.
  4. Any library that prints progress bars, banners or warnings to stdout causes the same failure. Redirect or silence it, and keep all logging on stderr; the spec explicitly allows a server to write anything to stderr and tells clients not to treat stderr as an error signal.
AdvancedStreamable HTTP in 2026-07-28

The 2026-07-28 revision kept the single POST endpoint and the JSON-or-SSE responses, and removed the stateful parts. The shapes below are from its transport page and weren't run here.

A 2026-07-28 tools/call over HTTP (from the spec)
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather","arguments":{"location":"Seattle, WA"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"ExampleClient","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
  • No sessions. Mcp-Session-Id is gone; a 2026-07-28-only server ignores it if an old client sends one. State that must span calls travels as explicit handles in tool arguments.
  • No GET stream, no DELETE, no resumability. A modern-only server answers GET or DELETE on the endpoint with 405. Long-lived change notifications come from a subscriptions/listen POST whose response is an open SSE stream. Last-Event-ID resumption was removed: if a response stream breaks, the client re-sends the request with a new id.
  • Routing headers are required. Mcp-Method on every request and Mcp-Name (the tool or prompt name, or the resource URI) on tools/call, resources/read and prompts/get, so gateways can route and rate-limit without parsing JSON. The server must check that the headers match the body and reject a mismatch with 400 and -32020 HeaderMismatch.
  • Cancellation is closing the request's SSE stream; on stdio it's still a notifications/cancelled message.
  • Unchanged security rules: validate the Origin header on every request and answer 403 if it's invalid (this stops DNS-rebinding attacks from web pages against a server on localhost), bind local servers to 127.0.0.1 rather than 0.0.0.0, and authenticate connections.

#Client features: roots, sampling and elicitation

Added

Tools, resources and prompts flow from server to client. Client features go the other way: things a server can ask of the client while it handles a request, because only the client's side has them. Roots answer "which folders or URIs should I work in?" (the client lists file:// URIs for the user's open projects). 2025-11-25 describes roots as defining "the boundaries of where servers can operate"; 2026-07-28 clarifies that they are "informational guidance rather than an access-control mechanism": the protocol doesn't stop a server going outside them. Sampling answers "can your model write this for me?": the server asks the host's LLM for a completion, so the server needs no model or API key of its own and the host keeps control of what's sent. Elicitation answers "can you ask the user?": the server requests structured input (a form) or sends the user to a URL for something sensitive. Each is opt-in: the client must declare the capability (roots, sampling, elicitation) or the server mustn't use it.

Two things changed in 2026-07-28, and both matter in an interview. First, roots and sampling (and logging) are Deprecated (SEP-2577): they still work during the deprecation window, new implementations shouldn't add them, and the registry lists the earliest removal as the first revision released on or after 2027-07-28. The suggested migrations are to pass directories as tool parameters, resource URIs or server configuration instead of roots, and to call LLM provider APIs directly instead of sampling. Elicitation stays, and is the one client feature listed on the 2026-07-28 overview page. Second, the mechanism changed. In 2025-11-25 the server sends its own JSON-RPC request back to the client in the middle of handling the client's request. In 2026-07-28 a server can't send requests at all; it answers the original request with an input_required result listing what it needs, and the client retries the original request with the answers. The spec calls this multi round-trip requests (MRTR).

The mental model for the change: in 2025-11-25 the server phones you back mid-task ("before I copy those logs, can you confirm the folder?"), which only works if it can reach you on the same line and remembers what it was doing. In 2026-07-28 the server hands the form back: "I can't finish this yet; fill in these fields and submit your request again, and bring this sealed note." Like a web form that comes back with "please also confirm your password", nothing waits on the server between the two submissions.

2025-11-25: server sends a request client server tools/call (id 1) elicitation/create (id 7) asks the user… result (id 7): accept result (id 1): done server holds id 1 open and remembers it 2026-07-28: server returns a form client server tools/call (id 1) input_required: inputRequests + requestState asks the user… tools/call (id 2) + answers complete: done nothing held open between them
Asking the client for input. 2025-11-25 nests a server-to-client request inside an open call; 2026-07-28's multi round-trip requests finish the first call and let the client retry with the answers.

Here are all three client features in 2025-11-25 form, as SDK 1.32.2 runs them, with every message printed after the handshake. The tool collect_logs needs a folder from the client's roots, the user's confirmation and a summary from the host's model.

client-features.mjs
// A tool that needs three things only the client has: its roots, its LLM and its user.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { ListRootsRequestSchema, CreateMessageRequestSchema, ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

const server = new McpServer({ name: "rooms", version: "1.0.0" });
server.registerTool("collect_logs", { description: "Bundle a room's logs", inputSchema: { roomId: z.string() } },
  async ({ roomId }) => {
    const { roots } = await server.server.listRoots(); // where may I write?
    const answer = await server.server.elicitInput({ // ask the user
      message: `Copy ${roomId} logs into ${roots[0].uri}?`,
      requestedSchema: { type: "object", properties: { includeCallHistory: { type: "boolean" } }, required: ["includeCallHistory"] },
    });
    if (answer.action !== "accept") return { content: [{ type: "text", text: `Cancelled (${answer.action})` }] };
    const summary = await server.server.createMessage({ // borrow the host's model
      messages: [{ role: "user", content: { type: "text", text: "Summarise: 09:02 wifi drop; 09:05 call ended" } }],
      maxTokens: 60,
    });
    return { content: [{ type: "text", text: `Saved to ${roots[0].uri}/${roomId}.zip. ${summary.content.text}` }] };
  });

const client = new Client({ name: "host", version: "1.0.0" },
  { capabilities: { roots: {}, elicitation: { form: {} }, sampling: {} } });
client.setRequestHandler(ListRootsRequestSchema, async () => ({ roots: [{ uri: "file:///home/rohit/support", name: "support" }] }));
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: "accept", content: { includeCallHistory: false } }));
client.setRequestHandler(CreateMessageRequestSchema, async () => ({
  role: "assistant", content: { type: "text", text: "A Wi-Fi drop ended the call." }, model: "fake-llm", stopReason: "endTurn" }));

const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
const show = (dir, m) => { if (m.method !== "initialize" && !m.result?.protocolVersion && m.method !== "notifications/initialized") console.log(dir, JSON.stringify(m)); };
const send = clientSide.send.bind(clientSide);
clientSide.send = (m, o) => (show("host → server", m), send(m, o));
let onmessage;
Object.defineProperty(clientSide, "onmessage", { get: () => onmessage, set: (fn) => (onmessage = (m, e) => (show("server → host", m), fn(m, e))) });

await server.connect(serverSide);
await client.connect(clientSide);
const result = await client.callTool({ name: "collect_logs", arguments: { roomId: "huddle-2" } });
console.log("tool result:", result.content[0].text);
await client.close();
Result
host → server {"method":"tools/call","params":{"name":"collect_logs","arguments":{"roomId":"huddle-2"}},"jsonrpc":"2.0","id":1}
server → host {"method":"roots/list","jsonrpc":"2.0","id":0}
host → server {"result":{"roots":[{"uri":"file:///home/rohit/support","name":"support"}]},"jsonrpc":"2.0","id":0}
server → host {"method":"elicitation/create","params":{"message":"Copy huddle-2 logs into file:///home/rohit/support?","requestedSchema":{"type":"object","properties":{"includeCallHistory":{"type":"boolean"}},"required":["includeCallHistory"]},"mode":"form"},"jsonrpc":"2.0","id":1}
host → server {"result":{"action":"accept","content":{"includeCallHistory":false}},"jsonrpc":"2.0","id":1}
server → host {"method":"sampling/createMessage","params":{"messages":[{"role":"user","content":{"type":"text","text":"Summarise: 09:02 wifi drop; 09:05 call ended"}}],"maxTokens":60},"jsonrpc":"2.0","id":2}
host → server {"result":{"model":"fake-llm","stopReason":"endTurn","role":"assistant","content":{"type":"text","text":"A Wi-Fi drop ended the call."}},"jsonrpc":"2.0","id":2}
server → host {"result":{"content":[{"type":"text","text":"Saved to file:///home/rohit/support/huddle-2.zip. A Wi-Fi drop ended the call."}]},"jsonrpc":"2.0","id":1}
tool result: Saved to file:///home/rohit/support/huddle-2.zip. A Wi-Fi drop ended the call.
What’s happening
  1. The client declares roots, elicitation: { form: {} } and sampling in its capabilities, and registers a handler for each request type. Declaring and handling go together: the SDK won't even let you register a handler for a capability you didn't declare (the next model shows that).
  2. The host sends tools/call (client id 1). While that call is open, the server sends its own request, roots/list, with server id 0. Ids are per sender: the client's id 1 and the server's ids 0, 1, 2 live in separate spaces, and each reply goes back to whoever asked. The client answers with one root, file:///home/rohit/support.
  3. Next the server sends elicitation/create (server id 1) with a message and a requestedSchema: a flat object with one boolean. The SDK added "mode": "form" itself. A real host would render a small form; this handler answers { action: "accept", content: { includeCallHistory: false } }, and the SDK validated that content against the schema before handing it to the tool.
  4. Then sampling/createMessage (server id 2) asks the host's model to summarise the log text with maxTokens: 60. The host decides which model runs it and can show or edit the request first; the fake handler answers as model fake-llm. The server never needed an API key.
  5. Only then does the server answer the original tools/call (client id 1) with the final text. The whole time, the server held that call open and kept its local variables (the roots, the answer) in memory. That's fine on stdio or an in-memory pair; across a load-balanced HTTP deployment it needs a session and sticky routing, which is the problem MRTR solves.
  6. In 2026-07-28 the same tool would answer the first tools/call with resultType: "input_required" listing the three requests (roots/list, elicitation/create, sampling/createMessage) in inputRequests, and the client would retry tools/call with inputResponses. And because roots and sampling are deprecated, a new server would take the folder as a tool argument or config instead and call an LLM API itself if it really needed one.
AdvancedWhen the client didn't declare the capability

The same three requests, against a client that declared nothing, then against a server running with enforceStrictCapabilities: true, then against a client that declared only elicitation and whose user clicks Decline.

missing-capability.mjs
// What happens when a server asks for a client feature the client never declared.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";

async function attempt(serverOptions, clientCapabilities, onElicit) {
  const server = new McpServer({ name: "rooms", version: "1.0.0" }, serverOptions);
  const tries = {
    roots: () => server.server.listRoots(),
    sampling: () => server.server.createMessage({ messages: [], maxTokens: 10 }),
    elicitation: () => server.server.elicitInput({ message: "Reboot huddle-2?", requestedSchema: { type: "object", properties: {} } }),
  };
  server.registerTool("probe", { description: "tries each client feature" }, async () => {
    const lines = [];
    for (const [name, fn] of Object.entries(tries)) {
      try { lines.push(`  ${name.padEnd(12)} ok: ${JSON.stringify(await fn())}`); }
      catch (e) { lines.push(`  ${name.padEnd(12)} ${e.message}`); }
    }
    return { content: [{ type: "text", text: lines.join("\n") }] };
  });
  const client = new Client({ name: "host", version: "1.0.0" }, { capabilities: clientCapabilities });
  if (onElicit) client.setRequestHandler(ElicitRequestSchema, onElicit);
  const [c, s] = InMemoryTransport.createLinkedPair();
  await server.connect(s);
  await client.connect(c);
  console.log((await client.callTool({ name: "probe", arguments: {} })).content[0].text);
  await client.close();
}

console.log("client declares nothing, default server:");
await attempt({}, {});
console.log("client declares nothing, server with enforceStrictCapabilities:");
await attempt({ enforceStrictCapabilities: true }, {});
console.log("client declares elicitation only, user clicks Decline:");
await attempt({}, { elicitation: {} }, async () => ({ action: "decline" }));

console.log("a handler for an undeclared capability:");
try {
  new Client({ name: "host", version: "1.0.0" }, { capabilities: {} }).setRequestHandler(ElicitRequestSchema, async () => ({ action: "cancel" }));
} catch (e) {
  console.log(" ", e.message);
}
Result
client declares nothing, default server:
  roots        MCP error -32601: Method not found
  sampling     MCP error -32601: Method not found
  elicitation  Client does not support form elicitation.
client declares nothing, server with enforceStrictCapabilities:
  roots        Client does not support listing roots (required for roots/list)
  sampling     Client does not support sampling (required for sampling/createMessage)
  elicitation  Client does not support form elicitation.
client declares elicitation only, user clicks Decline:
  roots        MCP error -32601: Method not found
  sampling     MCP error -32601: Method not found
  elicitation  ok: {"action":"decline"}
a handler for an undeclared capability:
  Client does not support elicitation capability (required for elicitation/create)
What’s happening
  1. Default server, client declares nothing. For roots/list and sampling/createMessage, SDK 1.32.2's server doesn't check the client's capabilities: it sends the requests, and the client, which has no handlers, answers -32601 Method not found. Elicitation is different: elicitInput checks the client's declared capabilities itself and throws Client does not support form elicitation. without sending anything.
  2. Strict server. With enforceStrictCapabilities: true, the server checks before sending all three, and refuses locally: Client does not support listing roots (required for roots/list) and so on. That's the behaviour the spec asks for (only use what was negotiated); strict mode makes the SDK enforce it for you.
  3. Elicitation only, user declines. Roots and sampling still fail, but elicitInput now goes through and returns { action: "decline" }. The three possible actions are accept (with content for a form), decline (the user explicitly said no) and cancel (dismissed without choosing). A tool must handle all three: the earlier collect_logs returns Cancelled (decline) for anything but accept. Note that elicitation: {} counted as form support: an empty elicitation capability means form mode only, for backwards compatibility.
  4. Handler without capability. On the client side, setRequestHandler(ElicitRequestSchema, …) on a client that didn't declare elicitation throws immediately: Client does not support elicitation capability (required for elicitation/create). The SDK keeps declaration and handling consistent.
  5. The lesson for a server author: check server.server.getClientCapabilities() before relying on a client feature, have a fallback when it's missing (take the value as a tool argument, return an isError result that says what's needed), and consider strict mode so mistakes fail loudly.
AdvancedElicitation rules worth knowing
  • Form mode (since 2025-06-18) collects non-sensitive data in-band. The requestedSchema is deliberately limited to a flat object of primitive properties: strings (with optional format email, uri, date, date-time), numbers or integers with minimum/maximum, booleans, and single- or multi-select enums; all can have defaults. No nesting, no arrays of objects.
  • Never ask for secrets in a form. The spec says servers must not use form mode for passwords, API keys, access tokens or payment details. For those it requires URL mode (added in 2025-11-25): the server sends a URL, the client shows the full URL and asks for consent, and the user completes the interaction in a browser, so the secret never passes through the client or the model. URL mode is for the server's own third-party access (for example connecting to another service on the user's behalf), not for authorising the client to the MCP server, which is the job of MCP authorization.
  • Clients must make it clear which server is asking, let the user review and change form answers before sending, and always offer decline and cancel. Servers must bind elicitation to the user's identity, not to anything the client merely claims.
AdvancedMulti round-trip requests, hand-written

SDK 1.32.2 doesn't implement 2026-07-28, so this model is a plain-JavaScript server and client written to the message shapes on the 2026-07-28 multi round-trip page: per-request _meta, resultType, inputRequests, inputResponses and an integrity-protected requestState. It's a model of the protocol's rules, not SDK code.

mrtr.mjs
// Hand-written to the 2026-07-28 message shapes (SDK 1.32.2 doesn't implement this revision).
// A stateless server asks for confirmation by returning input_required instead of sending a request.
import { createHmac } from "node:crypto";

const KEY = "demo-key-not-secret"; // a real server keeps this secret
const now = () => 1_000; // fake clock in seconds, so the output never changes
const sign = (payload) => {
  const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
  return `${body}.${createHmac("sha256", KEY).update(body).digest("base64url").slice(0, 16)}`;
};
const verify = (state) => {
  const [body, mac] = state.split(".");
  if (createHmac("sha256", KEY).update(body).digest("base64url").slice(0, 16) !== mac) return null;
  return JSON.parse(Buffer.from(body, "base64url").toString());
};

function handle(req) { // the whole server: every request carries everything it needs
  const meta = req.params._meta ?? {};
  const err = (code, message, data) => ({ jsonrpc: "2.0", id: req.id, error: { code, message, data } });
  const version = meta["io.modelcontextprotocol/protocolVersion"];
  if (version !== "2026-07-28") return err(-32022, "Unsupported protocol version", { supported: ["2026-07-28"], requested: version });
  const caps = meta["io.modelcontextprotocol/clientCapabilities"] ?? {};
  const { name, arguments: args, inputResponses, requestState } = req.params;
  if (name !== "reboot_room") return err(-32602, `Unknown tool: ${name}`);
  if (!requestState) {
    if (!caps.elicitation) return err(-32021, "Missing required client capability", { requiredCapabilities: { elicitation: {} } });
    return { jsonrpc: "2.0", id: req.id, result: { resultType: "input_required",
      inputRequests: { confirm: { method: "elicitation/create", params: { mode: "form", message: `Reboot ${args.roomId}? Its call will drop.`,
        requestedSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] } } } },
      requestState: sign({ tool: name, roomId: args.roomId, exp: now() + 300 }) } };
  }
  const state = verify(requestState);
  if (!state || state.roomId !== args.roomId || state.exp < now()) return err(-32602, "Invalid requestState");
  const ok = inputResponses?.confirm?.action === "accept" && inputResponses.confirm.content.ok;
  const text = ok ? `Rebooting ${args.roomId}` : `Not rebooted: the user said no`;
  return { jsonrpc: "2.0", id: req.id, result: { resultType: "complete", content: [{ type: "text", text }] } };
}

let id = 0;
const call = (params, caps = { elicitation: { form: {} } }, version = "2026-07-28") => {
  const req = { jsonrpc: "2.0", id: ++id, method: "tools/call", params: { ...params, _meta: {
    "io.modelcontextprotocol/protocolVersion": version, "io.modelcontextprotocol/clientCapabilities": caps } } };
  const res = handle(req);
  console.log(`#${req.id} ${JSON.stringify(res.error ?? { resultType: res.result.resultType, ...(res.result.content ? { text: res.result.content[0].text } : { asks: Object.keys(res.result.inputRequests), requestState: res.result.requestState }) })}`);
  return res;
};

const first = call({ name: "reboot_room", arguments: { roomId: "huddle-2" } });
const { requestState } = first.result;
call({ name: "reboot_room", arguments: { roomId: "huddle-2" }, requestState, inputResponses: { confirm: { action: "accept", content: { ok: true } } } });
call({ name: "reboot_room", arguments: { roomId: "board-1" }, requestState, inputResponses: { confirm: { action: "accept", content: { ok: true } } } });
call({ name: "reboot_room", arguments: { roomId: "huddle-2" }, requestState: requestState.replace(/.$/, "x"), inputResponses: {} });
call({ name: "reboot_room", arguments: { roomId: "huddle-2" } }, {});
call({ name: "reboot_room", arguments: { roomId: "huddle-2" } }, {}, "2025-11-25");
Result
#1 {"resultType":"input_required","asks":["confirm"],"requestState":"eyJ0b29sIjoicmVib290X3Jvb20iLCJyb29tSWQiOiJodWRkbGUtMiIsImV4cCI6MTMwMH0.PkHyLHb1jQVR_l3Q"}
#2 {"resultType":"complete","text":"Rebooting huddle-2"}
#3 {"code":-32602,"message":"Invalid requestState"}
#4 {"code":-32602,"message":"Invalid requestState"}
#5 {"code":-32021,"message":"Missing required client capability","data":{"requiredCapabilities":{"elicitation":{}}}}
#6 {"code":-32022,"message":"Unsupported protocol version","data":{"supported":["2026-07-28"],"requested":"2025-11-25"}}
What’s happening
  1. Request #1 asks to reboot huddle-2 and declares elicitation: { form: {} } in _meta. The server needs a confirmation, so instead of sending elicitation/create as a request it returns resultType: "input_required" with inputRequests: { confirm: … } (the key confirm is the server's own name for that question) and a requestState. Request #1 is now finished; the server keeps nothing in memory.
  2. The requestState is the "sealed note": base64url JSON ({"tool":"reboot_room","roomId":"huddle-2","exp":1300}) plus an HMAC (a keyed hash: only a holder of the server's secret key can produce a matching one, so any change to the note is detected). The spec requires servers to treat a returned requestState as attacker-controlled and, when it affects authorisation or business logic, to protect its integrity (HMAC, or AEAD encryption, which also hides the contents) and reject anything that fails verification. It also recommends binding it to the authenticated user, a short expiry and the original request; this model has no authentication, so it binds the tool, the room and an expiry on a fake clock (now() is always 1000, so exp is 1300 and the output never changes).
  3. Request #2 is a new request with a new id, as the spec requires: the same name and arguments, the exact requestState echoed back, and inputResponses.confirm = { action: "accept", content: { ok: true } }. The server verifies the HMAC, checks the room matches and the state hasn't expired, and completes: Rebooting huddle-2. Any server instance holding the key could have handled it.
  4. Request #3 replays the same state for a different room (board-1), and #4 tampers with the state's last character. Both fail verification and get -32602 Invalid requestState. Without the integrity check, a client could have edited the note to approve something the user never saw.
  5. Request #5 declares no elicitation capability. The server can't ask, so it answers -32021 Missing required client capability with data.requiredCapabilities: { elicitation: {} }, the 2026-07-28 error for exactly this case (the spec also forbids a server from putting a request type the client didn't declare into inputRequests).
  6. Request #6 says it speaks 2025-11-25. This modern-only toy answers -32022 Unsupported protocol version with supported: ["2026-07-28"], and a client would retry with that version. Compare the SDK 1.32.2 server, which simply fails a modern probe with -32601: that difference is how a dual-era client tells the eras apart.
Retry or fall back?

A dual-era host connects over stdio to two servers and sends server/discover with "io.modelcontextprotocol/protocolVersion": "2026-07-28" to each. Server A replies {"code":-32601,"message":"Method not found"}. Server B replies {"code":-32022,"message":"Unsupported protocol version","data":{"supported":["2026-09-30"],"requested":"2026-07-28"}}. What should the host do with each?

Show answer

Server A: treat it as legacy and fall back to the initialize handshake. Server B: it's modern; don't fall back. Retry with a version from supported if the host speaks one, otherwise show the user an error.

What’s happening
  1. The 2026-07-28 stdio binding defines three outcomes for the probe: a DiscoverResult (modern), a recognised modern error such as UnsupportedProtocolVersionError (modern, wrong version), or anything else including silence (legacy).
  2. Server A's -32601 isn't a modern MCP error code, so A is legacy. That's exactly what SDK 1.32.2 answered in the negotiation model. The host sends initialize and continues with 2025-11-25 rules for the life of that server process, and may cache that decision.
  3. Server B's -32022 is a modern error with a supported list, so B is modern but wants a different revision. The spec says not to fall back to initialize in that case. If the host supports 2026-09-30 (a made-up future revision for this question), it retries server/discover (or any request) with that version in _meta; if not, it reports an incompatibility to the user.
  4. Falling back to initialize with B would fail anyway: a modern-only server rejects initialize, which to it is an unknown method missing the required _meta.
  5. The rule: fall back only when the reply is not a recognised modern error, and never key the decision to one specific legacy error code, since legacy servers answer unknown methods in implementation-defined ways or not at all.

Written fresh for a role that asks for GitHub Copilot and MCP experience. MCP examples were run with @modelcontextprotocol/sdk 1.32.2; Copilot facts come from GitHub's documentation, cited with dates.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.