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.
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 integrations
With a shared protocol
4 AI apps, 6 systems
4 × 6 = 24 integrations to build and keep working
4 MCP clients + 6 MCP servers = 10
A system changes its API
fix it in up to 4 places, in 4 codebases
fix its one server
A new AI app arrives
it integrates all 6 systems itself
it implements MCP once and gets all 6
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 }=awaitimport("@modelcontextprotocol/sdk/server/mcp.js");const{ StdioServerTransport }=awaitimport("@modelcontextprotocol/sdk/server/stdio.js");const{ z }=awaitimport("zod");// zod: a schema library; the SDK turns these schemas into JSON Schema for the clientconst server =newMcpServer({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 stdoutreturn{content:[{type:"text",text:`${roomId}: in a call, camera on`}]};});await server.connect(newStdioServerTransport());}else{// Two different hosts. Neither contains any code specific to the rooms server.const{ Client }=awaitimport("@modelcontextprotocol/sdk/client/index.js");const{ StdioClientTransport }=awaitimport("@modelcontextprotocol/sdk/client/stdio.js");const config ={command: process.execPath,args:[fileURLToPath(import.meta.url),"server"]};// like a host's config entryfor(const hostName of["ide-assistant","support-chat"]){const client =newClient({name: hostName,version:"1.0.0"});const transport =newStdioClientTransport({...config,stderr:"pipe"});let serverLog ="";
transport.stderr.on("data",(d)=>(serverLog += d));await client.connect(transport);// spawns its own copy of the server processconst{ tools }=await client.listTools();// discovered at runtime, not compiled inconst 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
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.
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.
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.
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.
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).
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
Point-to-point, every app needs its own code for every system: 5 × 8 = 40.
With MCP each app implements the client side once (5), and each system gets one server (8): 5 + 8 = 13.
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.
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.
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.
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.
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.
Role
Who builds it
What it does
Examples
Host
The AI app's vendor, or you for an in-house agent
Runs 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 policy
VS Code with Copilot Chat, Claude Desktop, Claude Code, your own agent
Client
Usually an SDK class used by the host
Speaks MCP to one server: negotiates the version and capabilities, sends requests, routes notifications, keeps that server's state separate
Client from @modelcontextprotocol/sdk/client
Server
The owner of a system (or anyone wrapping it)
Declares and implements tools, resources and prompts; enforces its own access control; can be local or remote
A 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";functionroomsServer(){const s =newMcpServer({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;}functionticketsServer(){const s =newMcpServer({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();awaitmake().connect(s);
clients[name]=newClient({name:"toy-host",version:"1.0.0"});await clients[name].connect(c);}const catalogue =newMap();// what the model sees → which client, which real toolfor(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(", "));constaskUser=(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 serversif(!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
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.
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.
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.
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.
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.
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
The host is the application the user runs: VS Code with Copilot Chat. One host.
The host creates one client per configured server: 3 clients, all inside the VS Code process (they're objects, not processes).
There are 3 servers, but they don't all run on the laptop.
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.
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.
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.
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 }=awaitimport("@modelcontextprotocol/sdk/server/mcp.js");const{ StdioServerTransport }=awaitimport("@modelcontextprotocol/sdk/server/stdio.js");const{ z }=awaitimport("zod");const server =newMcpServer({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(newStdioServerTransport());// reads stdin, writes stdout}else{const child =spawn(process.execPath,[fileURLToPath(import.meta.url),"server"],{stdio:["pipe","pipe","inherit"],});const replies =newMap();// request id → resolve functioncreateInterface({input: child.stdout }).on("line",(line)=>{
console.log(`← ${line}`);const msg =JSON.parse(line);
replies.get(msg.id)?.(msg);});constsend=(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)returnnewPromise((resolve)=> replies.set(msg.id, resolve));};awaitsend({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 replyawaitsend({jsonrpc:"2.0",id:2,method:"tools/list"});awaitsend({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
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.
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.
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.
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.
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.
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.
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:
Side
Capability
What it promises
Sub-capabilities
Client
roots
can tell the server which folders/URIs are in scope
listChanged
Client
sampling
the server may ask the host's LLM for a completion
tools (sampling with tool calls)
Client
elicitation
the server may ask the user for input
form, url
Client / server
tasks
experimental: long-running requests as pollable tasks
per request type
Server
tools
tools/list, tools/call
listChanged
Server
resources
resources/list, resources/read, templates
subscribe, listChanged
Server
prompts
prompts/list, prompts/get
listChanged
Server
logging
the server can send log messages
Server
completions
argument auto-completion (completion/complete)
Both
experimental
non-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 }=awaitimport("@modelcontextprotocol/sdk/server/mcp.js");const{ StdioServerTransport }=awaitimport("@modelcontextprotocol/sdk/server/stdio.js");const server =newMcpServer({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(newStdioServerTransport());}else{const child =spawn(process.execPath,[fileURLToPath(import.meta.url),"server"],{stdio:["pipe","pipe","inherit"]});const replies =newMap();createInterface({input: child.stdout }).on("line",(line)=>{const msg =JSON.parse(line);
replies.get(msg.id)?.(msg);});let nextId =1;constask=(label, method, params)=>{const id = nextId++;
child.stdin.write(JSON.stringify({jsonrpc:"2.0", id, method, params })+"\n");returnnewPromise((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 =awaitask("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?awaitask("tools/list before initialize","tools/list");// 3. Ask for a version the server doesn't know.const init =awaitask("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.awaitask("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
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?".
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.
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.
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.
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 =newMcpServer({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 =newClient({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 clientawait 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 =awaitcall();
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
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.
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.
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.
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)
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.
Code
Name
Where it comes from
-32700
Parse error
JSON-RPC 2.0: the bytes weren't valid JSON
-32600
Invalid request
JSON-RPC 2.0: valid JSON, not a valid request
-32601
Method not found
JSON-RPC 2.0: no handler for that method (and the legacy-server answer to the probe)
-32602
Invalid params
JSON-RPC 2.0: bad arguments; MCP also uses it for unknown tools, unknown prompts and (from 2026-07-28) unknown resources
-32603
Internal error
JSON-RPC 2.0: the handler failed
-32000 to -32019
implementation-defined
2026-07-28 calls these legacy; SDK 1.32.2 uses -32000 (connection closed / bad HTTP request) and -32001 (request timeout, session not found)
-32002
Resource not found
2025-11-25 and earlier only; clients should still accept it
-32020
HeaderMismatch
2026-07-28: HTTP headers don't match the body
-32021
MissingRequiredClientCapability
2026-07-28
-32022
UnsupportedProtocolVersion
2026-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
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.
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.
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.
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.
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.
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.
Field
Required
What it's for
name
yes
Programmatic id, unique within the server. 2025-11-25 guidance: 1–128 characters, only A–Z a–z 0–9 _ - ., case-sensitive
title
no
Human-friendly display name for the host's UI
description
in practice, yes
What it does and when to use it. The model reads this to choose
inputSchema
yes
JSON Schema object for arguments. For no arguments: { "type": "object" }, or, as both 2025-11-25 and 2026-07-28 recommend, { "type": "object", "additionalProperties": false }
outputSchema
no
JSON Schema for structuredContent; if present the server must conform and clients should validate
annotations
no
Behaviour hints: readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title. Untrusted unless the server is trusted
icons
no
Images 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.
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 =newMcpServer({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 textstructuredContent: status,// typed data matching outputSchema};},);const client =newClient({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
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.
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.
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.
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.
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.
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 =newMcpServer({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")thrownewError("ECONNREFUSED 10.0.4.17:443");// an unexpected failureif(roomId ==="lobby")// an expected business failure, written for the model to act onreturn{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 typeconst client =newClient({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
works: a normal result, isError absent (printed as false).
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.
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.
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.
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.
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.
Annotation
Meaning when true
Default
Typical host use
readOnlyHint
doesn't modify its environment
false
may run without confirmation
destructiveHint
may delete or overwrite (only meaningful if not read-only)
true
stronger warning
idempotentHint
repeating with the same arguments has no extra effect
false
safe to retry
openWorldHint
talks to an open set of external things (web search) rather than a closed domain
true
flag 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
(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.
(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").
(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).
(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.
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.
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?"
Tool
Resource
Controlled by
the model
the application (often on the user's choice)
Identified by
a name, with an input schema
a URI (or a URI template)
Side effects
may have them
should have none: it's a read
Typical UI
runs during the agent loop, with approval if risky
context picker, @-mentions, attachments
Good for
actions and parameterised queries
documents, 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 =newMcpServer({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",newResourceTemplate("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))thrownewMcpError(ErrorCode.InvalidParams,`No room called ${roomId}`,{uri: uri.href });if(rooms[roomId]===null)thrownewError(`${roomId} device is offline`);// a plain Errorreturn{contents:[{uri: uri.href,mimeType:"application/json",text:JSON.stringify(rooms[roomId])}]};});const client =newClient({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();
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.
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.
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.
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.
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.
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 =newSet();// URIs this connection subscribed toconst mcp =newMcpServer({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),{}));asyncfunctiondeviceChanged(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 handshakeconst 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 =newClient({name:"host",version:"1.0.0"});const updated =newPromise((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"});awaitdeviceChanged("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
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.
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.
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.
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.
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".
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:
Primitive
Controlled by
What the user sees
Methods (2025-11-25)
Device-platform example
Tools
the model
the agent calling get_room_status, with an approval prompt for risky ones
tools/list, tools/call
read status, set volume, reboot (with confirmation)
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 =newMcpServer({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 =newClient({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
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.
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.
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).
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.
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
(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.
(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.
(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.
(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.
The test each time is "who decides when this is used?": model → tool, application/user picking data → resource, user picking a workflow → prompt.
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.
The two standard transports. The JSON-RPC messages are identical; only the framing and who runs the server differ.
stdio
Streamable HTTP
Who runs the server
the host, as a child process
the server's owner, as a service
Clients per server
one
many
Where it runs
the user's machine, with the user's permissions
anywhere reachable: your cloud, a vendor's, or localhost
Credentials
the spec says stdio servers shouldn't use the MCP authorization spec and should take credentials from the environment (env vars in the host config)
local files, git, a CLI wrapper, a dev's own tools
shared team servers, SaaS integrations (GitHub's, a ticket tracker's), anything multi-user
Deploying an update
every user updates their copy
update 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 =newMcpServer({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 =newStreamableHTTPServerTransport({sessionIdGenerator:()=>randomUUID()});await mcp.connect(transport);const http =createServer((req, res)=> transport.handleRequest(req, res));awaitnewPromise((resolve)=> http.listen(0,"127.0.0.1", resolve));// port 0: any free portconst url =`http://127.0.0.1:${http.address().port}/mcp`;let session;// random per run, so it's printed as <session-id>consthide=(s)=>(session ? s.replaceAll(session,"<session-id>"): s);asyncfunctionpost(label, body, headers ={}){const res =awaitfetch(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"));}awaitpost("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"};awaitpost("POST notifications/initialized",{jsonrpc:"2.0",method:"notifications/initialized"}, sessionHeaders);awaitpost("POST tools/call",{jsonrpc:"2.0",id:2,method:"tools/call",params:{name:"list_rooms",arguments:{}}}, sessionHeaders);awaitpost("POST tools/list, no session header",{jsonrpc:"2.0",id:3,method:"tools/list"},{"mcp-protocol-version":"2025-11-25"});awaitpost("POST tools/list, unknown session",{jsonrpc:"2.0",id:4,method:"tools/list"},{...sessionHeaders,"mcp-session-id":"made-up"});awaitpost("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
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.
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.
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).
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.
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.
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 }=awaitimport("@modelcontextprotocol/sdk/server/mcp.js");const{ StdioServerTransport }=awaitimport("@modelcontextprotocol/sdk/server/stdio.js");const server =newMcpServer({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 channelif(mode ==="console.log") console.log("debug: status called");// stdout: an extra, invalid lineif(mode ==="stdout.write") process.stdout.write("debug: ");// stdout, no newline: glued to the replyreturn{content:[{type:"text",text:"online"}]};});await server.connect(newStdioServerTransport());}else{const{ Client }=awaitimport("@modelcontextprotocol/sdk/client/index.js");const{ StdioClientTransport }=awaitimport("@modelcontextprotocol/sdk/client/stdio.js");for(const mode of["console.error","console.log","stdout.write"]){const transport =newStdioClientTransport({command: process.execPath,args:[fileURLToPath(import.meta.url),"server", mode],stderr:"pipe"});let stderr ="";
transport.stderr.on("data",(d)=>(stderr += d));const client =newClient({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
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.
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.
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.
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.
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.
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.
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 =newMcpServer({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 usermessage:`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 modelmessages:[{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 =newClient({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();constshow=(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
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).
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.
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.
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.
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.
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";asyncfunctionattempt(serverOptions, clientCapabilities, onElicit){const server =newMcpServer({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(awaitfn())}`);}catch(e){ lines.push(`${name.padEnd(12)}${e.message}`);}}return{content:[{type:"text",text: lines.join("\n")}]};});const client =newClient({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:");awaitattempt({},{});
console.log("client declares nothing, server with enforceStrictCapabilities:");awaitattempt({enforceStrictCapabilities:true},{});
console.log("client declares elicitation only, user clicks Decline:");awaitattempt({},{elicitation:{}},async()=>({action:"decline"}));
console.log("a handler for an undeclared capability:");try{newClient({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
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.
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.
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.
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.
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 formatemail, 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";constKEY="demo-key-not-secret";// a real server keeps this secretconstnow=()=>1_000;// fake clock in seconds, so the output never changesconstsign=(payload)=>{const body = Buffer.from(JSON.stringify(payload)).toString("base64url");return`${body}.${createHmac("sha256",KEY).update(body).digest("base64url").slice(0,16)}`;};constverify=(state)=>{const[body, mac]= state.split(".");if(createHmac("sha256",KEY).update(body).digest("base64url").slice(0,16)!== mac)returnnull;returnJSON.parse(Buffer.from(body,"base64url").toString());};functionhandle(req){// the whole server: every request carries everything it needsconst meta = req.params._meta ??{};consterr=(code, message, data)=>({jsonrpc:"2.0",id: req.id,error:{ code, message, data }});const version = meta["io.modelcontextprotocol/protocolVersion"];if(version !=="2026-07-28")returnerr(-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")returnerr(-32602,`Unknown tool: ${name}`);if(!requestState){if(!caps.elicitation)returnerr(-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())returnerr(-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");
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.
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).
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.
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.
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).
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
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).
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.
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.
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.
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.